Compare commits
150
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dc0caeae9 | ||
|
|
9647ead8d4 | ||
|
|
c587268c97 | ||
|
|
ce683f94bd | ||
|
|
3dd8c7c2dd | ||
|
|
de71572b9d | ||
|
|
029afc21c4 | ||
|
|
64dbb30e70 | ||
|
|
28acad6865 | ||
|
|
5f808f3033 | ||
|
|
15b1838e21 | ||
|
|
822314e8b7 | ||
|
|
5027bd1e73 | ||
|
|
f44987e391 | ||
|
|
b6cc762d23 | ||
|
|
f1638b2fee | ||
|
|
7c0e278ee8 | ||
|
|
93c9660421 | ||
|
|
430fc2673c | ||
|
|
b79db9098a | ||
|
|
16e0761ddf | ||
|
|
5f5672fed3 | ||
|
|
1dafb4bc79 | ||
|
|
d279fe8f90 | ||
|
|
82e217155b | ||
|
|
b5c073955d | ||
|
|
b0564679e6 | ||
|
|
724ff0b077 | ||
|
|
a666cdbcdc | ||
|
|
5855da0ba9 | ||
|
|
c3d2dc2418 | ||
|
|
54b316ae36 | ||
|
|
3c4f6a9f8e | ||
|
|
2a323d6270 | ||
|
|
d282813bc5 | ||
|
|
d599e7404f | ||
|
|
9b497af756 | ||
|
|
73a1bad29f | ||
|
|
a9923c48d9 | ||
|
|
5b21720312 | ||
|
|
f2aaa9cea4 | ||
|
|
6632231815 | ||
|
|
7d9d5b005c | ||
|
|
23738501c7 | ||
|
|
91dda348bc | ||
|
|
3240c56e84 | ||
|
|
136c754bcd | ||
|
|
aa0d594666 | ||
|
|
53ccaad468 | ||
|
|
d51d523ce1 | ||
|
|
d90a1cef93 | ||
|
|
829c5ab14d | ||
|
|
1b9058fccb | ||
|
|
a2ae868f28 | ||
|
|
520de85d12 | ||
|
|
36ad85feef | ||
|
|
9418d3f935 | ||
|
|
3e8b1ebb38 | ||
|
|
38fb78a095 | ||
|
|
6f3aba06a6 | ||
|
|
a389b9e8c5 | ||
|
|
0ad19802f8 | ||
|
|
1592f38515 | ||
|
|
acc50f009c | ||
|
|
f42fb014c8 | ||
|
|
a73525f425 | ||
|
|
82470e8db0 | ||
|
|
f39d6ac30c | ||
|
|
4e61adfe80 | ||
|
|
b65732ea04 | ||
|
|
166a04f578 | ||
|
|
79d891c623 | ||
|
|
d13cf6ed6b | ||
|
|
b56ffdf268 | ||
|
|
0ebdb38a98 | ||
|
|
35935f2d3c | ||
|
|
8173e22ccb | ||
|
|
7825097333 | ||
|
|
cd6dff5346 | ||
|
|
0e05bee69a | ||
|
|
dc676acf52 | ||
|
|
1a955d64df | ||
|
|
575f634f9c | ||
|
|
fdfb83b254 | ||
|
|
17a24fe880 | ||
|
|
9e7723ca66 | ||
|
|
befe1dbf53 | ||
|
|
a875274a8e | ||
|
|
276ecfccff | ||
|
|
a35f360952 | ||
|
|
2464c9655f | ||
|
|
c66308e4bb | ||
|
|
6432e11beb | ||
|
|
db7b103a08 | ||
|
|
2c47c0851c | ||
|
|
f569f2cc7a | ||
|
|
3fd0d0cfa6 | ||
|
|
ed93fefb9b | ||
|
|
6098ffb8e5 | ||
|
|
01f721d8d1 | ||
|
|
5356e603fe | ||
|
|
fafeee481e | ||
|
|
a618f3fca6 | ||
|
|
7d6dfe4581 | ||
|
|
c84f190f76 | ||
|
|
7aa2e374d4 | ||
|
|
45c8929697 | ||
|
|
6a467d9bc4 | ||
|
|
e93d42d27e | ||
|
|
e9349863e8 | ||
|
|
3a74f0a715 | ||
|
|
1f9c17ad18 | ||
|
|
0df62e6b2f | ||
|
|
eca8468d84 | ||
|
|
429c232e0c | ||
|
|
53a44d7d18 | ||
|
|
fa22d30347 | ||
|
|
fcbd8f6449 | ||
|
|
310dc85b62 | ||
|
|
0c9a15a691 | ||
|
|
cee107d948 | ||
|
|
f201b09e90 | ||
|
|
029f079094 | ||
|
|
4d23cef511 | ||
|
|
b4248a6661 | ||
|
|
1f8c12e29e | ||
|
|
17d98748c4 | ||
|
|
1070ee13bc | ||
|
|
71827a2d04 | ||
|
|
5dd0a56732 | ||
|
|
b55c8b13bc | ||
|
|
0cf9b81444 | ||
|
|
8386444ac7 | ||
|
|
e1ae97139c | ||
|
|
503eaf17ec | ||
|
|
1e02d9ebba | ||
|
|
d40dbf04b8 | ||
|
|
2a9e18f04c | ||
|
|
4d89f5e672 | ||
|
|
95e5c69e8f | ||
|
|
50d08a18e4 | ||
|
|
9a634311b2 | ||
|
|
b811c84b12 | ||
|
|
b9b01ce02c | ||
|
|
104e3c7ba0 | ||
|
|
0a03c64ff3 | ||
|
|
4c430ea995 | ||
|
|
112b3ea52f | ||
|
|
31edf33839 | ||
|
|
1a842d8d14 |
@@ -61,6 +61,12 @@ 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
|
||||||
|
# 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
|
||||||
|
# and settings. Stalwart's own admin interface is not affected.
|
||||||
|
ADMINISTRATION=1
|
||||||
|
|
||||||
# Branding
|
# Branding
|
||||||
APP_NAME=ihasmail
|
APP_NAME=ihasmail
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Funding platforms shown behind the repository's Sponsor button.
|
||||||
|
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||||
|
|
||||||
|
github: jcoffey-dev
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# The npm entry sits at the root because that is where the single lockfile
|
||||||
|
# is: root, server and web are one npm workspace, so one entry covers all
|
||||||
|
# three. Pointing entries at server/ or web/ would find package.json files
|
||||||
|
# with no lockfile beside them and update nothing.
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: tuesday
|
||||||
|
time: "09:00"
|
||||||
|
timezone: Etc/UTC
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
groups:
|
||||||
|
# Everything routine arrives as one PR a week, so the dashboard is not
|
||||||
|
# the only place these get noticed. Majors are deliberately left out of
|
||||||
|
# the group: they are migrations, not bumps -- vitest 3 to 4 is one --
|
||||||
|
# and each deserves its own PR and its own CI run.
|
||||||
|
minor-and-patch:
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: tuesday
|
||||||
|
time: "09:00"
|
||||||
|
timezone: Etc/UTC
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
# The runtime and build stages both pin node:22-alpine, so this is what
|
||||||
|
# keeps the published container images off a stale base between the weekly
|
||||||
|
# releases.
|
||||||
|
- package-ecosystem: docker
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: tuesday
|
||||||
|
time: "09:00"
|
||||||
|
timezone: Etc/UTC
|
||||||
@@ -15,10 +15,10 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 26
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci --ignore-scripts
|
- run: npm ci --ignore-scripts
|
||||||
- run: npm run typecheck
|
- run: npm run typecheck
|
||||||
|
|||||||
@@ -76,13 +76,13 @@ jobs:
|
|||||||
version: ${{ steps.v.outputs.version }}
|
version: ${{ steps.v.outputs.version }}
|
||||||
docker_tag: ${{ steps.v.outputs.docker_tag }}
|
docker_tag: ${{ steps.v.outputs.docker_tag }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 26
|
||||||
- id: v
|
- id: v
|
||||||
run: |
|
run: |
|
||||||
V="$(node scripts/version.mjs)"
|
V="$(node scripts/version.mjs)"
|
||||||
@@ -108,18 +108,18 @@ jobs:
|
|||||||
- platform: linux/arm64
|
- platform: linux/arm64
|
||||||
runner: ubuntu-24.04-arm
|
runner: ubuntu-24.04-arm
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v4
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
@@ -140,7 +140,7 @@ jobs:
|
|||||||
# `image@sha256:sha256:...` when the reference is rebuilt.
|
# `image@sha256:sha256:...` when the reference is rebuilt.
|
||||||
digest="${{ steps.push.outputs.digest }}"
|
digest="${{ steps.push.outputs.digest }}"
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
# One artifact per platform; the merge job globs them back together.
|
# One artifact per platform; the merge job globs them back together.
|
||||||
name: digest-${{ strategy.job-index }}
|
name: digest-${{ strategy.job-index }}
|
||||||
@@ -157,13 +157,13 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digest-*
|
pattern: digest-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v4
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -46,13 +46,13 @@ jobs:
|
|||||||
previous: ${{ steps.decide.outputs.previous }}
|
previous: ${{ steps.decide.outputs.previous }}
|
||||||
count: ${{ steps.decide.outputs.count }}
|
count: ${{ steps.decide.outputs.count }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 26
|
||||||
- id: decide
|
- id: decide
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -122,7 +122,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|||||||
@@ -7,5 +7,3 @@ server/data/
|
|||||||
.vite/
|
.vite/
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
# Worktrees used by parallel agents; never part of a commit.
|
|
||||||
.claude/worktrees/
|
|
||||||
|
|||||||
@@ -47,3 +47,104 @@ bg #e6e7ed · bg_dark #d6d8df · fg #343b59 · line numbers #9da0ab · border #c
|
|||||||
link #2959aa
|
link #2959aa
|
||||||
accents: purple #65359d · red #8c4351 · cyan #006c86 · blue #2959aa
|
accents: purple #65359d · red #8c4351 · cyan #006c86 · blue #2959aa
|
||||||
yellow #8f5e15 · teal #33635c · green #385f0d
|
yellow #8f5e15 · teal #33635c · green #385f0d
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Fetched 2026-09-06 from the projects' own repositories, same rule as above.
|
||||||
|
Where a project publishes fewer background tiers than ihasmail needs, the
|
||||||
|
missing one is derived and marked **derived** here rather than passed off as
|
||||||
|
upstream. Body text is lifted to 7:1 by the build script for most of these —
|
||||||
|
they target their own ~4.5:1 — and every shift is printed in the generated CSS.
|
||||||
|
|
||||||
|
## Catppuccin — catppuccin/palette, MIT (palette.json)
|
||||||
|
Cited from the palette repo rather than the hub README; it is the normative
|
||||||
|
machine-readable source.
|
||||||
|
|
||||||
|
### Mocha (dark)
|
||||||
|
base #1e1e2e · mantle #181825 · crust #11111b · surface0 #313244 · surface1 #45475a
|
||||||
|
text #cdd6f4 · subtext0 #a6adc8 · overlay1 #7f849c
|
||||||
|
mauve #cba6f7 · blue #89b4fa · red #f38ba8 · peach #fab387 · green #a6e3a1
|
||||||
|
yellow #f9e2af · pink #f5c2e7
|
||||||
|
|
||||||
|
### Latte (light)
|
||||||
|
base #eff1f5 · mantle #e6e9ef · crust #dce0e8 · surface0 #ccd0da · surface1 #bcc0cc
|
||||||
|
text #4c4f69 · subtext0 #6c6f85
|
||||||
|
mauve #8839ef · blue #1e66f5 · red #d20f39 · peach #fe640b · green #40a02b
|
||||||
|
yellow #df8e1d · pink #ea76cb
|
||||||
|
|
||||||
|
Latte publishes no tier lighter than `base`, so `base` is used as the elevated
|
||||||
|
surface and `mantle` as the page behind it.
|
||||||
|
|
||||||
|
## Solarized — altercation/solarized, MIT (README "The Values")
|
||||||
|
base03 #002b36 · base02 #073642 · base01 #586e75 · base00 #657b83
|
||||||
|
base0 #839496 · base1 #93a1a1 · base2 #eee8d5 · base3 #fdf6e3
|
||||||
|
yellow #b58900 · orange #cb4b16 · red #dc322f · magenta #d33682
|
||||||
|
violet #6c71c4 · blue #268bd2 · cyan #2aa198 · green #859900
|
||||||
|
|
||||||
|
The accents are shared by both modes by design. Two tiers are **derived**: the
|
||||||
|
sunken dark surface #001f28 (below base03) and the raised light surface
|
||||||
|
#fffdf6 (above base3), neither of which Solarized publishes, plus the two
|
||||||
|
rule colours #0d4552 and #e6dfc8.
|
||||||
|
|
||||||
|
## Everforest — sainnhe/everforest, MIT (palette.md), medium contrast
|
||||||
|
### Dark
|
||||||
|
bg_dim #232a2e · bg0 #2d353b · bg1 #343f44 · bg3 #475258
|
||||||
|
fg #d3c6aa · grey1 #859289
|
||||||
|
red #e67e80 · orange #e69875 · yellow #dbbc7f · green #a7c080 · aqua #83c092
|
||||||
|
blue #7fbbb3 · purple #d699b6
|
||||||
|
|
||||||
|
### Light
|
||||||
|
bg_dim #efebd4 · bg0 #fdf6e3 · bg3 #e6e2cc · bg5 #bdc3af
|
||||||
|
fg #5c6a72 · grey1 #939f91
|
||||||
|
red #f85552 · orange #f57d26 · yellow #dfa000 · green #8da101 · aqua #35a77c
|
||||||
|
blue #3a94c5 · purple #df69ba
|
||||||
|
|
||||||
|
Light uses bg_dim as the page and bg0 as the raised surface, so the card the
|
||||||
|
reader looks at is the colour Everforest calls its background.
|
||||||
|
|
||||||
|
## Kanagawa — rebelot/kanagawa.nvim, MIT (lua/kanagawa/colors.lua)
|
||||||
|
### Wave (dark)
|
||||||
|
sumiInk0 #16161D · sumiInk3 #1F1F28 · sumiInk4 #2A2A37 · sumiInk5 #363646
|
||||||
|
fujiWhite #DCD7BA · fujiGray #727169
|
||||||
|
crystalBlue #7E9CD8 · springBlue #7FB4CA · samuraiRed #E82424 · roninYellow #FF9E3B
|
||||||
|
springGreen #98BB6C · carpYellow #E6C384 · sakuraPink #D27E99
|
||||||
|
|
||||||
|
### Lotus (light)
|
||||||
|
lotusWhite0 #d5cea3 · lotusWhite1 #dcd5ac · lotusWhite2 #e5ddb0 · lotusWhite3 #f2ecbc
|
||||||
|
lotusInk1 #545464 · lotusGray2 #716e61
|
||||||
|
lotusViolet4 #624c83 · lotusBlue4 #4d699b · lotusRed #c84053 · lotusOrange #cc6d00
|
||||||
|
lotusGreen #6f894e · lotusYellow #77713f · lotusPink #b35b79
|
||||||
|
|
||||||
|
## Ayu — ayu-theme/ayu-colors, MIT (themes/dark.yaml, themes/light.yaml)
|
||||||
|
The YAMLs give the base palette and the surfaces as literals but express syntax
|
||||||
|
roles as references (`$palette.indigo.l2`), and the resolved files are not
|
||||||
|
committed. The two signature accents are taken from the same organisation's
|
||||||
|
MIT-licensed ayu-theme/vscode-ayu build.
|
||||||
|
|
||||||
|
### Dark
|
||||||
|
surface base #0D1017 · lift #10141C (sunk is `base -L0.1`, **derived** here as #070a0f)
|
||||||
|
ui line #1B1F29 · ui fg #5A6378 · editor fg #BFBDB6
|
||||||
|
red #F07178 · orange #FF8F40 · yellow #FFB454 · green #AAD94C · teal #95E6CB
|
||||||
|
indigo #39BAE6 · blue #59C2FF · purple #D2A6FF · accent #E6B450 (vscode-ayu)
|
||||||
|
|
||||||
|
### Light
|
||||||
|
surface sunk #EBEEF0 · base #F8F9FA · lift #FCFCFC
|
||||||
|
ui fg #828E9F · editor fg #5C6166 · rule #dfe2e5 (**derived**)
|
||||||
|
red #F07171 · orange #FA8532 · yellow #EBA400 · green #86B300 · teal #4CBF99
|
||||||
|
indigo #55B4D4 · blue #22A4E6 · purple #A37ACC · accent #F29718 (vscode-ayu)
|
||||||
|
|
||||||
|
## Primer — primer/primitives, MIT (src/tokens/base/color/{dark,light})
|
||||||
|
Named "Primer" after the design system. The colour values are MIT; "GitHub"
|
||||||
|
and the Invertocat are trademarks, and nothing here is endorsed by them.
|
||||||
|
|
||||||
|
### Dark
|
||||||
|
neutral #0D1117 #151B23 #212830 #262C36 #2A313C #2F3742 #3D444D #656C76
|
||||||
|
#9198A1 #B7BDC8 #D1D7E0 #F0F6FC · black #010409
|
||||||
|
blue #79c0ff #58a6ff · green #56d364 #3fb950 · yellow #e3b341 #d29922
|
||||||
|
red #ff7b72 · purple #d2a8ff
|
||||||
|
|
||||||
|
### Light
|
||||||
|
neutral #F6F8FA #EFF2F5 #E6EAEF #E0E6EB #DAE0E7 #D1D9E0 #C8D1DA #818B98
|
||||||
|
#59636E #454C54 #393F46 #25292E
|
||||||
|
blue #0969da #0550ae · green #1a7f37 #116329 · yellow #bf8700 #9a6700
|
||||||
|
red #cf222e · purple #8250df
|
||||||
|
|||||||
@@ -48,6 +48,15 @@ For larger changes, please open an issue to discuss the approach **before** subm
|
|||||||
- Related issue number(s), if any
|
- Related issue number(s), if any
|
||||||
- Screenshots/GIFs for UI changes
|
- Screenshots/GIFs for UI changes
|
||||||
- Any manual testing you performed
|
- Any manual testing you performed
|
||||||
|
8. **Add translations** for any new user-visible string — see
|
||||||
|
[Translations](#translations) below — and **drive the built app** for any
|
||||||
|
change that is visible on screen, as described in
|
||||||
|
[Verifying UI work](#verifying-ui-work).
|
||||||
|
|
||||||
|
`main` is protected. A change reaches it through a pull request whose **build**
|
||||||
|
check has passed — not afterwards — and the branch cannot be force-pushed or
|
||||||
|
deleted. No approving review is required, so a PR of your own is not blocked
|
||||||
|
waiting for one.
|
||||||
|
|
||||||
### Code Style
|
### Code Style
|
||||||
|
|
||||||
@@ -56,6 +65,56 @@ For larger changes, please open an issue to discuss the approach **before** subm
|
|||||||
- Prefer clarity over cleverness — this is a mail client people rely on for their inbox.
|
- Prefer clarity over cleverness — this is a mail client people rely on for their inbox.
|
||||||
- Comment non-obvious JMAP interactions, especially around state/`changes` handling, since JMAP's delta-sync model can be easy to get subtly wrong.
|
- Comment non-obvious JMAP interactions, especially around state/`changes` handling, since JMAP's delta-sync model can be easy to get subtly wrong.
|
||||||
|
|
||||||
|
### Translations
|
||||||
|
|
||||||
|
Nine languages ship alongside English: German, Spanish, French, Dutch,
|
||||||
|
Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, in
|
||||||
|
`web/src/locales/`. A missing key renders its English source rather than
|
||||||
|
failing, so an untranslated string is invisible until somebody reading that
|
||||||
|
language finds it.
|
||||||
|
|
||||||
|
**Any change that adds or alters a user-visible string adds work in all nine
|
||||||
|
catalogues.** Say so explicitly in the PR — how many keys, and the fallback
|
||||||
|
count before and after — and say so just as explicitly when a change adds none,
|
||||||
|
so it is never left to be inferred.
|
||||||
|
|
||||||
|
#### The catalogue key for a plural is the `other` form
|
||||||
|
|
||||||
|
`plural()` looks the entry up by `forms.other`, so a call site written as
|
||||||
|
|
||||||
|
```ts
|
||||||
|
plural(n, { one: "Deleted {n} contact", other: "Deleted {n} contacts" })
|
||||||
|
```
|
||||||
|
|
||||||
|
is keyed on **`"Deleted {n} contacts"`**. Keying the catalogue on the `one`
|
||||||
|
form type-checks, builds, passes every test, and silently falls back to English
|
||||||
|
in all nine languages. Nothing errors. The only signal is the fallback count
|
||||||
|
going up, so read it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run i18n:check # literals wrapped, and catalogue health
|
||||||
|
node scripts/i18n-catalog-check.mjs # per-language: translated / used / falling back
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare the "falling back to English" number against `main` before and after.
|
||||||
|
It should not rise. Do not read the percentage instead — adding keys moves the
|
||||||
|
denominator, so it can hold steady while new strings go untranslated.
|
||||||
|
|
||||||
|
Plural forms are per language, from `Intl.PluralRules`: `one`/`other` for most,
|
||||||
|
`one`/`few`/`many`/`other` for Russian and Ukrainian, `other` alone for Japanese
|
||||||
|
and Chinese. Supplying a form a language does not draw is inventing a
|
||||||
|
distinction, not being thorough.
|
||||||
|
|
||||||
|
### Verifying UI work
|
||||||
|
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
run it: `npm run dev:mock` (mock Stalwart, credentials printed on start), then
|
||||||
|
drive the real thing. Add a component test for what you find; there are
|
||||||
|
examples in `web/src/views/*/__tests__/`.
|
||||||
|
|
||||||
### Development Setup
|
### Development Setup
|
||||||
|
|
||||||
1. Clone your fork:
|
1. Clone your fork:
|
||||||
|
|||||||
+17
-5
@@ -1,5 +1,5 @@
|
|||||||
# ---- build stage ----
|
# ---- build stage ----
|
||||||
FROM node:22-alpine AS build
|
FROM node:26-alpine AS build
|
||||||
# What this build calls itself: 2.16.<PR>, worked out by whoever runs the
|
# What this build calls itself: 2.16.<PR>, worked out by whoever runs the
|
||||||
# build. It cannot be worked out in here -- .dockerignore keeps .git out of the
|
# build. It cannot be worked out in here -- .dockerignore keeps .git out of the
|
||||||
# context on purpose, and git is not installed either. `node scripts/version.mjs`
|
# context on purpose, and git is not installed either. `node scripts/version.mjs`
|
||||||
@@ -25,7 +25,7 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- runtime stage ----
|
# ---- runtime stage ----
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:26-alpine AS runtime
|
||||||
# Re-declared: an ARG does not cross stages.
|
# Re-declared: an ARG does not cross stages.
|
||||||
ARG IHASMAIL_VERSION=""
|
ARG IHASMAIL_VERSION=""
|
||||||
ARG BASE_PATH=""
|
ARG BASE_PATH=""
|
||||||
@@ -37,16 +37,28 @@ ENV NODE_ENV=production \
|
|||||||
IHASMAIL_VERSION=$IHASMAIL_VERSION \
|
IHASMAIL_VERSION=$IHASMAIL_VERSION \
|
||||||
BASE_PATH=$BASE_PATH
|
BASE_PATH=$BASE_PATH
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json package-lock.json* ./
|
||||||
COPY server/package.json server/
|
COPY server/package.json server/
|
||||||
# config.ts reads the version through this at startup. With IHASMAIL_VERSION
|
# config.ts reads the version through this at startup. With IHASMAIL_VERSION
|
||||||
# set it never looks further; without it, it falls back to package.json rather
|
# set it never looks further; without it, it falls back to package.json rather
|
||||||
# than failing, since there is no git in here to ask.
|
# than failing, since there is no git in here to ask.
|
||||||
COPY scripts/ ./scripts/
|
COPY scripts/ ./scripts/
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
# Only what the server loads at runtime: hono and its Node adapter, about 4 MB.
|
||||||
|
# The build stage's tree is 132 MB of vite, TypeScript, esbuild and React that
|
||||||
|
# never executes here but shipped anyway -- and showed up in every CVE scan.
|
||||||
|
RUN npm ci --ignore-scripts --omit=dev --workspace server \
|
||||||
|
&& rm -rf /root/.npm /tmp/*
|
||||||
COPY --from=build /app/server/dist ./server/dist
|
COPY --from=build /app/server/dist ./server/dist
|
||||||
COPY --from=build /app/web/dist ./web/dist
|
COPY --from=build /app/web/dist ./web/dist
|
||||||
RUN mkdir -p /data && chown -R node:node /data /app
|
# /data is the only path the process may write. /app stays root-owned and
|
||||||
|
# read-only to the runtime user on purpose; the previous `chown -R /app`
|
||||||
|
# re-wrote every file and, on overlayfs, duplicated the whole tree into a
|
||||||
|
# second 173 MB layer.
|
||||||
|
RUN mkdir -p /data && chown node:node /data \
|
||||||
|
# The base image ships a package manager the server never calls. Anyone who
|
||||||
|
# gets code execution should not find one waiting for them.
|
||||||
|
&& rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx \
|
||||||
|
/usr/local/bin/corepack /opt/yarn* /usr/local/bin/yarn /usr/local/bin/yarnpkg
|
||||||
USER node
|
USER node
|
||||||
# No `VOLUME ["/data"]`. It reads like documentation for where the session file
|
# No `VOLUME ["/data"]`. It reads like documentation for where the session file
|
||||||
# goes, but Docker acts on it: a container started without `-v` gets an
|
# goes, but Docker acts on it: a container started without `-v` gets an
|
||||||
|
|||||||
+340
-19
@@ -12,8 +12,12 @@ questions:
|
|||||||
| [KNOWN-ISSUES.md](KNOWN-ISSUES.md) | What was verified live, and where Stalwart departs from a spec |
|
| [KNOWN-ISSUES.md](KNOWN-ISSUES.md) | What was verified live, and where Stalwart departs from a spec |
|
||||||
| [docs.ihasmail.org](https://docs.ihasmail.org) | How to install, configure and drive each of these |
|
| [docs.ihasmail.org](https://docs.ihasmail.org) | How to install, configure and drive each of these |
|
||||||
|
|
||||||
Written against the tree at Stalwart **0.16.20**, which is the version the live
|
Written against the tree at Stalwart **0.16.22**, which is the version the live
|
||||||
instance runs and the one every behaviour below was checked against. ihasmail
|
instance runs. Behaviours carrying an older version below were checked against
|
||||||
|
that one and have not changed since; where a later release changed something,
|
||||||
|
the entry says so and names both. 0.16.22 changed nothing described here: its
|
||||||
|
client-visible changes are in what `CalendarEvent/get` and `ContactCard/get`
|
||||||
|
return, and [KNOWN-ISSUES.md](KNOWN-ISSUES.md) lists them. ihasmail
|
||||||
requires 0.16 or newer and refuses older servers at sign-in, by name.
|
requires 0.16 or newer and refuses older servers at sign-in, by name.
|
||||||
|
|
||||||
## The shape of it
|
## The shape of it
|
||||||
@@ -365,7 +369,15 @@ same query string — so what it builds can be read, edited and learned from.
|
|||||||
these headers shows nothing.
|
these headers shows nothing.
|
||||||
- **Message body theming** is off by default — sender HTML is left exactly as it
|
- **Message body theming** is off by default — sender HTML is left exactly as it
|
||||||
was designed, on a light card. One setting lets mail that brings no colours of
|
was designed, on a light card. One setting lets mail that brings no colours of
|
||||||
its own follow the app's theme instead.
|
its own follow the app's theme instead. That is a low bar in practice: one
|
||||||
|
`color:#FFFFFF` on one button label opts a whole message out, so for mail
|
||||||
|
built from a template it changed nothing. A second setting, off unless the
|
||||||
|
first is on, forces the theme over the sender's own colours. It tells a
|
||||||
|
*sheet* the design sits on, like a white wrapper table, from a *painted
|
||||||
|
surface* like a button or a banner, by relative luminance: the first is
|
||||||
|
neutralised so the bright card goes away, the second is kept whole so its
|
||||||
|
label stays readable on it. Nothing the sender wrote is removed, so the
|
||||||
|
switch is reversible, and print is unaffected either way.
|
||||||
|
|
||||||
### Conversations
|
### Conversations
|
||||||
|
|
||||||
@@ -536,6 +548,24 @@ nothing for anybody else.
|
|||||||
- **iCal import** through `CalendarEvent/parse` (a file of any number of
|
- **iCal import** through `CalendarEvent/parse` (a file of any number of
|
||||||
events), from the calendar's own menu, into that calendar. The events are
|
events), from the calendar's own menu, into that calendar. The events are
|
||||||
filed rather than scheduled: no invitations go out to anyone named in them.
|
filed rather than scheduled: no invitations go out to anyone named in them.
|
||||||
|
- **Re-importing updates rather than duplicates**, as a contacts import does.
|
||||||
|
An event is recognised by its UID, per calendar, and what the file carries
|
||||||
|
wins -- so a corrected export corrects what the first attempt got wrong.
|
||||||
|
|
||||||
|
Two things are deliberately left alone: **who accepted**, and **edits to a
|
||||||
|
single occurrence**. Both are answers and decisions taken here after the file
|
||||||
|
was written, and a file that mentions them at all describes them as they were
|
||||||
|
at export, so writing either one over would throw away work silently and
|
||||||
|
return no error anywhere. A corrected export therefore fixes the time, the
|
||||||
|
title and the location, and leaves the RSVPs and the "just this Wednesday"
|
||||||
|
changes where they are.
|
||||||
|
|
||||||
|
The cost runs both ways and is worth knowing. An attendee added at the source
|
||||||
|
since the last import does not arrive, because nothing here can tell that
|
||||||
|
apart from an answer given in ihasmail. And an import still sends no
|
||||||
|
scheduling messages, so an event a re-import moves is moved *here* --
|
||||||
|
everybody else's copy still says the old time until whoever is organising
|
||||||
|
sends the update from the event itself.
|
||||||
- **Subscribed calendars** by URL — a timetable, a rota, a public holiday list.
|
- **Subscribed calendars** by URL — a timetable, a rota, a public holiday list.
|
||||||
Added in Settings › Calendar & contacts, read-only, and shown beside your own
|
Added in Settings › Calendar & contacts, read-only, and shown beside your own
|
||||||
with their own colour.
|
with their own colour.
|
||||||
@@ -662,13 +692,18 @@ work:
|
|||||||
success; the rest are applied. ihasmail checks the patch before sending it, so
|
success; the rest are applied. ihasmail checks the patch before sending it, so
|
||||||
a rejected property is an error you can see and an inherited one is reported
|
a rejected property is an error you can see and an inherited one is reported
|
||||||
as something it could not do for one date, rather than claimed as saved.
|
as something it could not do for one date, rather than claimed as saved.
|
||||||
- **Occurrence ids are not stable across a write.** Stalwart's synthetic ids
|
- **Occurrence ids became stable in 0.16.21, and were not before it.** Through
|
||||||
encode a position in the expanded series, and writing an override renumbers
|
0.16.20 Stalwart's synthetic ids encoded a *position* in the expanded series,
|
||||||
them — confirmed live on 0.16.20: after one override, the same five ids
|
so writing one override renumbered the rest and the same five ids addressed a
|
||||||
addressed a different five dates. So an occurrence is re-resolved from its
|
different five dates. 0.16.21 identifies an occurrence by its recurrence id
|
||||||
`recurrenceId` (the date itself) immediately before it is touched, and a
|
instead — confirmed live on 0.16.21 (2026-09-06): a five-week series was
|
||||||
vanished date says so rather than acting on an id that now means something
|
expanded, its third occurrence retitled through its own synthetic id, and all
|
||||||
else.
|
five original ids re-read afterwards still named their own dates. ihasmail
|
||||||
|
re-resolves an occurrence from its `recurrenceId` immediately before touching
|
||||||
|
it anyway. That is no longer load-bearing on the current server, and it stays
|
||||||
|
because it costs one lookup, because a vanished date still has to say so
|
||||||
|
rather than be acted on, and because the client supports 0.16 as a whole
|
||||||
|
rather than only its newest release.
|
||||||
|
|
||||||
*This and future* is not offered: the server refuses an occurrence that belongs
|
*This and future* is not offered: the server refuses an occurrence that belongs
|
||||||
to such a change, and where it does, ihasmail says so and offers the series.
|
to such a change, and where it does, ihasmail says so and offers the series.
|
||||||
@@ -711,6 +746,12 @@ JMAP Contacts and JSContact.
|
|||||||
company, job title, any number of emails, phones and addresses with types,
|
company, job title, any number of emails, phones and addresses with types,
|
||||||
birthday, website and notes.
|
birthday, website and notes.
|
||||||
- **Groups** as a card kind, with members picked from the book.
|
- **Groups** as a card kind, with members picked from the book.
|
||||||
|
- **Select and delete in bulk** — tick rows in the list, shift-click for a run,
|
||||||
|
and delete the lot; or **Empty address book** from the book's own menu, which
|
||||||
|
is the operation a migration asks for when an import needs doing again. A card
|
||||||
|
filed in two books is only ever removed from the one being emptied, since
|
||||||
|
deleting it would empty a book nobody asked about, and what is reported
|
||||||
|
afterwards is what the server confirmed rather than what was asked for.
|
||||||
- **Letter index** down the list, with `#` for everything that does not start
|
- **Letter index** down the list, with `#` for everything that does not start
|
||||||
with a letter.
|
with a letter.
|
||||||
- **Search** across name, address, organisation and notes, in one book or all.
|
- **Search** across name, address, organisation and notes, in one book or all.
|
||||||
@@ -724,6 +765,14 @@ JMAP Contacts and JSContact.
|
|||||||
title, nickname, web pages and the custom fields all come across. The import
|
title, nickname, web pages and the custom fields all come across. The import
|
||||||
control takes either format and decides by what is in the file, not by what it
|
control takes either format and decides by what is in the file, not by what it
|
||||||
is called.
|
is called.
|
||||||
|
- **Re-importing updates rather than duplicates.** A vCard is recognised by its
|
||||||
|
UID; an LDIF entry, whose schema has none, by its distinguished name. The card
|
||||||
|
already here is merged with the file's version -- what the file carries wins,
|
||||||
|
what it does not mention is left alone -- so a corrected export can correct
|
||||||
|
what the first attempt got wrong. Matching is per address book, which is also
|
||||||
|
how two directories that each hold a `cn=John Smith` stay two people. An entry
|
||||||
|
no longer recognisable, because its `dn` moved between exports, is imported
|
||||||
|
again and counted: *"3 of them look like contacts you already had."*
|
||||||
|
|
||||||
[ldif-schema]: https://wiki.mozilla.org/MailNews:Mozilla_LDAP_Address_Book_Schema
|
[ldif-schema]: https://wiki.mozilla.org/MailNews:Mozilla_LDAP_Address_Book_Schema
|
||||||
- **Directory lookup** through `Principal/query`, so colleagues on the server
|
- **Directory lookup** through `Principal/query`, so colleagues on the server
|
||||||
@@ -1005,11 +1054,17 @@ at two.
|
|||||||
| **Gruvbox** | |
|
| **Gruvbox** | |
|
||||||
| **Rosé Pine** | Dawn as its light half |
|
| **Rosé Pine** | Dawn as its light half |
|
||||||
| **Tokyo Night** | Day as its light half |
|
| **Tokyo Night** | Day as its light half |
|
||||||
|
| **Catppuccin** | Mocha and Latte |
|
||||||
|
| **Solarized** | Light and dark are both original to it, and share one set of accents |
|
||||||
|
| **Ayu** | |
|
||||||
|
| **Kanagawa** | Wave, with Lotus as its light half |
|
||||||
|
| **Everforest** | The medium-contrast variant of each side |
|
||||||
|
| **Primer** | The colours behind GitHub's design system. Named for the system, not for GitHub, which has not endorsed anything here |
|
||||||
|
|
||||||
Every one has both halves, so the top-bar toggle only ever changes the side and
|
Every one has both halves, so the top-bar toggle only ever changes the side and
|
||||||
never the colours. Accent colours still sit on top of any of them.
|
never the colours. Accent colours still sit on top of any of them.
|
||||||
|
|
||||||
The four borrowed palettes are the work of their own projects and are used
|
The ten borrowed palettes are the work of their own projects and are used
|
||||||
under the MIT licence — see [NOTICE](NOTICE). Only the published colour values
|
under the MIT licence — see [NOTICE](NOTICE). Only the published colour values
|
||||||
are used, taken from each project's own repository; the values as fetched are
|
are used, taken from each project's own repository; the values as fetched are
|
||||||
recorded in `.palette-sources/palettes-upstream.md`.
|
recorded in `.palette-sources/palettes-upstream.md`.
|
||||||
@@ -1023,11 +1078,131 @@ anything that falls short, towards white on a dark ground and towards black on
|
|||||||
a light one so the hue survives. The script refuses to write a palette that
|
a light one so the hue survives. The script refuses to write a palette that
|
||||||
would not pass.
|
would not pass.
|
||||||
|
|
||||||
That check is not a formality. **Every one of the nine palette halves needed at
|
That check is not a formality. **Twenty-one of the twenty-two palette halves
|
||||||
least one lift**, because these palettes are designed for code editors rather
|
needed at least one lift**, because these palettes are designed for code
|
||||||
than for prose at this size: Dracula's comment grey is 3.03:1 on its own
|
editors rather than for prose at this size: Dracula's comment grey is 3.03:1 on
|
||||||
background, and Rosé Pine's gold is 2.7:1 on Dawn. Shipping them as published
|
its own background, and Rosé Pine's gold is 2.7:1 on Dawn. Shipping them as
|
||||||
would have quietly ended the WCAG AA claim two sections down.
|
published would have quietly ended the WCAG AA claim two sections down.
|
||||||
|
|
||||||
|
Body text is lifted the same way, which it was not at first. It used to be
|
||||||
|
checked and then either accepted or rejected, and that rule would have turned
|
||||||
|
away five of the six palettes added in September 2026: most of them target
|
||||||
|
around 4.5:1 for body text, their own goal, where ihasmail asks 7:1 of the text
|
||||||
|
a reader looks at all day. Rejecting a palette over a bar its designers never
|
||||||
|
aimed at is the wrong answer when the same arithmetic already adjusts muted
|
||||||
|
text, links and accents. Solarized Light moves 4.13 to 7.07 that way; Primer
|
||||||
|
needed nothing in either half.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Administration
|
||||||
|
|
||||||
|
An account whose Stalwart role manages other accounts finds **Administration**
|
||||||
|
in the account menu, top right. Nobody else sees the entry, and the page
|
||||||
|
redirects them to their mail if they type its address in.
|
||||||
|
|
||||||
|
## What it offers is what the role allows
|
||||||
|
|
||||||
|
At sign-in the server already asks Stalwart's `GET /api/account` for the
|
||||||
|
edition; it now keeps the account's **permissions** from the same answer and
|
||||||
|
hands them to the browser with the session. The menu appears for an account
|
||||||
|
that can query and read accounts (`sysAccountQuery`, `sysAccountGet`) or
|
||||||
|
domains (`sysDomainQuery`, `sysDomainGet`), and each control inside is there only when the matching permission is:
|
||||||
|
**New account** with `sysAccountCreate`, editing with `sysAccountUpdate`,
|
||||||
|
**Delete** with `sysAccountDestroy`. A system administrator, a tenant
|
||||||
|
administrator and a custom helpdesk role each see the same screen shaped to
|
||||||
|
what they can do.
|
||||||
|
|
||||||
|
None of that is the security boundary. Every read and write is a JMAP `x:`
|
||||||
|
call through the ordinary `/api/jmap` proxy, authenticated as the signed-in
|
||||||
|
account, and Stalwart decides each one — scoping a tenant administrator's
|
||||||
|
queries to their own tenant and refusing anything the role does not allow.
|
||||||
|
The client's gating only avoids offering what would fail.
|
||||||
|
|
||||||
|
## Accounts
|
||||||
|
|
||||||
|
- **List and search** by name or address, fifty to a page, newest first — the
|
||||||
|
server's own order. Role, storage used against the limit, and groups at a
|
||||||
|
glance.
|
||||||
|
- **Create** an account on any domain the role can see: display name, address,
|
||||||
|
a generated password to copy and pass on, role, and storage limit.
|
||||||
|
- **Edit** the display name, other addresses (aliases), role and storage limit.
|
||||||
|
One save sends only what changed.
|
||||||
|
- **Set a new password.** It goes into the account's existing password
|
||||||
|
credential, and signs the person out of every app and device using the old
|
||||||
|
one, because Stalwart ties every token to the password.
|
||||||
|
- **Delete**, after typing the address to confirm. Stalwart removes the
|
||||||
|
mailbox's data in the background, and says so.
|
||||||
|
|
||||||
|
Roles are offered only when the viewer holds every permission they carry,
|
||||||
|
which is the check Stalwart makes on a grant. It does **not** make that check
|
||||||
|
when only a password changes, or on a delete, so an account allowed to edit
|
||||||
|
accounts could otherwise reset the password of one that can do more and sign
|
||||||
|
in as it. ihasmail shows any account that outranks the viewer read-only, and
|
||||||
|
counts a role it cannot read as outranking rather than not. Nobody can change
|
||||||
|
their own role or delete the account they are signed in with.
|
||||||
|
|
||||||
|
## Domains
|
||||||
|
|
||||||
|
For a role that can read domains (`sysDomainQuery`, `sysDomainGet`):
|
||||||
|
|
||||||
|
- **List and search**, with how many accounts use each domain and whether its
|
||||||
|
DNS records, DKIM keys and certificate are managed automatically or by hand.
|
||||||
|
- **Add** a domain. Stalwart gives a new one automatic DKIM, so it has keys
|
||||||
|
straight away.
|
||||||
|
- **Edit** the description, other names for the domain, the catch-all address,
|
||||||
|
and plus addressing (`name+anything@`). A plus-addressing rule set on the
|
||||||
|
server is shown and left alone.
|
||||||
|
- **DNS records**, one per row with a copy button each, and the lot as a zone
|
||||||
|
file. Stalwart computes them per domain — MX, SPF, DKIM, DMARC, the service
|
||||||
|
records, MTA-STS, TLS reporting, CAA — and ihasmail joins a long DKIM record
|
||||||
|
back into the single value a DNS provider's form wants.
|
||||||
|
- **DKIM keys** with their stage — signing, published and waiting, retiring —
|
||||||
|
read-only, because the server creates and rotates them itself when DKIM is
|
||||||
|
automatic, and a key added by hand needs its private key.
|
||||||
|
- **Remove** a domain once nothing uses it. While accounts do, removal says how
|
||||||
|
many and stays unavailable. The domain's own DKIM keys go with it, since the
|
||||||
|
server will not remove a domain its keys still name — which also means a role
|
||||||
|
that cannot delete keys cannot remove a domain that has any.
|
||||||
|
|
||||||
|
Switching DNS, DKIM or certificate management between automatic and manual,
|
||||||
|
and choosing a DNS or ACME provider, stay in Stalwart's own interface for now.
|
||||||
|
|
||||||
|
## Only on your own device
|
||||||
|
|
||||||
|
Administration is available only to a session signed in with **"This is my own
|
||||||
|
device"** ticked. A borrowed laptop or a shared machine is exactly where nobody
|
||||||
|
should be able to reset a password or remove a domain, and that tickbox is the
|
||||||
|
one question the sign-in page already asks about where it is being used.
|
||||||
|
|
||||||
|
It is enforced the same way as the switch below: an untrusted session is sent
|
||||||
|
no permissions, and the JMAP proxy refuses registry methods beyond the account's
|
||||||
|
own. The menu still shows **Administration** to an administrator in that
|
||||||
|
session, greyed out, with the reason and what to do about it — signing in again
|
||||||
|
with the box ticked — rather than losing the entry without a word. All the
|
||||||
|
server tells that session is that the account administers, never what it may do.
|
||||||
|
|
||||||
|
## An operator can turn it off
|
||||||
|
|
||||||
|
`ADMINISTRATION=0` at launch removes it for everyone, and not only from the
|
||||||
|
menu. The permissions are no longer sent to the browser, and the JMAP proxy
|
||||||
|
refuses Stalwart registry methods except the ones about the signed-in account
|
||||||
|
itself — its password, app passwords, API keys, public keys, masked addresses
|
||||||
|
and account settings. Without that, hiding the menu would leave an
|
||||||
|
administrator's browser console able to make every call the menu made.
|
||||||
|
Stalwart's own interface is unaffected; this decides what ihasmail offers.
|
||||||
|
|
||||||
|
## Stateless, as everything else
|
||||||
|
|
||||||
|
Nothing new is stored anywhere. There is no admin route on ihasmail's server,
|
||||||
|
no database and no cache beyond the permissions list that rides along with the
|
||||||
|
session information already kept for thirty minutes — so a role granted or
|
||||||
|
taken away shows in the menu at the next sign-in or within half an hour, and in
|
||||||
|
the meantime Stalwart refuses what is no longer allowed.
|
||||||
|
|
||||||
|
Accounts and domains are the first two sections. Groups, mailing lists, roles
|
||||||
|
and tenants are Stalwart capabilities the same screen is laid out to take;
|
||||||
|
reporting, queues, logs and server settings are deliberately out of scope.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1057,9 +1232,14 @@ would have quietly ended the WCAG AA claim two sections down.
|
|||||||
when the open page happened to be the root.
|
when the open page happened to be the root.
|
||||||
- **The subscription is renewed on every app start**, because a JMAP push
|
- **The subscription is renewed on every app start**, because a JMAP push
|
||||||
subscription expires — seven days is the ceiling — and re-registering before
|
subscription expires — seven days is the ceiling — and re-registering before
|
||||||
it lapses is the client's job. Renewal can only happen with a page open:
|
it lapses is the client's job. Renewal happens with a page open, and the
|
||||||
registering is a JMAP call and the service worker has no session to make one
|
reason is *when* the service worker runs rather than what it is allowed to
|
||||||
with. So the guarantee is that background notifications keep working as long
|
do: it only wakes for an event, and the event that would wake it is a push
|
||||||
|
that stops arriving the moment the subscription lapses. A renewal that can
|
||||||
|
only run while renewal is still unnecessary is no schedule at all. (This
|
||||||
|
page previously said the worker had no session to register with. That was
|
||||||
|
wrong — see **Acting on a notification** below.) So the guarantee is that
|
||||||
|
background notifications keep working as long
|
||||||
as ihasmail is opened now and again, and the two-day renewal window means
|
as ihasmail is opened now and again, and the two-day renewal window means
|
||||||
once a week is enough. A browser that dropped or rotated its subscription on
|
once a week is enough. A browser that dropped or rotated its subscription on
|
||||||
its own is re-subscribed at the same moment, rather than left with a switch
|
its own is re-subscribed at the same moment, rather than left with a switch
|
||||||
@@ -1079,6 +1259,76 @@ would have quietly ended the WCAG AA claim two sections down.
|
|||||||
installability and fast loads, API requests never are, and navigations are
|
installability and fast loads, API requests never are, and navigations are
|
||||||
network-first with the shell as fallback.
|
network-first with the shell as fallback.
|
||||||
- **Manifest shortcuts** for Compose, Calendar and Contacts.
|
- **Manifest shortcuts** for Compose, Calendar and Contacts.
|
||||||
|
- **One window, not one per launch.** A `mailto:` link, a shortcut or a
|
||||||
|
notification opened while ihasmail is already running arrives in the copy
|
||||||
|
that is running. Two windows on the same inbox disagree about what has been
|
||||||
|
read, and only one of them is where the half-written reply is.
|
||||||
|
- **The unread count on the installed app's icon.** The tab title and the
|
||||||
|
painted favicon are the same idea for a browser tab, and an installed app has
|
||||||
|
neither -- in `display: standalone` there is no tab strip and no favicon on
|
||||||
|
screen, so a home-screen ihasmail showed nothing at all. Web Push marks the
|
||||||
|
icon while the app is closed, with a dot rather than a figure: the service
|
||||||
|
worker is not told how many messages are unread — a push carries the new mail
|
||||||
|
rather than a total, so counting the payload would badge "2" over an inbox
|
||||||
|
holding forty. The next tab to open writes the real count over it. It could
|
||||||
|
now ask, which is a change since this was written; whether a badge is worth a
|
||||||
|
request on every push is a separate question and has not been answered yet.
|
||||||
|
Unsupported browsers show nothing, as does iOS until notification permission
|
||||||
|
has been granted, which is that platform's condition for a badge.
|
||||||
|
- **In the share sheet** — share a photo, a link or a file from any other app
|
||||||
|
and ihasmail is one of the places it can go, opening a draft that holds it.
|
||||||
|
The subject comes from the shared title, the text and the link become the
|
||||||
|
body above your signature, and files are attached and start uploading. It
|
||||||
|
addresses nothing: a share says what to send, never who to.
|
||||||
|
|
||||||
|
A share is a POST, which is not something a client-side router can answer, so
|
||||||
|
the service worker takes the body, leaves it where a tab can collect it and
|
||||||
|
redirects to the app. That indirection is also what lets a share to a
|
||||||
|
signed-out ihasmail work — it waits through the sign-in page and opens after,
|
||||||
|
which the query string could not have survived. One nobody comes back for
|
||||||
|
expires after ten minutes rather than opening a composer full of a forgotten
|
||||||
|
photo the next time you look. Android and Chromium only; iOS does not
|
||||||
|
implement share targets.
|
||||||
|
- **Acting on a notification.** Archive and Mark as read sit on the
|
||||||
|
notification itself, and both happen where you are — the phone stays in your
|
||||||
|
hand, or in your pocket. They are the two a phone shows: `maxActions` is two
|
||||||
|
on Android, and anything past it is dropped silently, so these are the two
|
||||||
|
worth having rather than the two that came first. Reply is deliberately not
|
||||||
|
among them, because it would have to open the app, and tapping the
|
||||||
|
notification already does that.
|
||||||
|
|
||||||
|
This was described here as impossible, and it is worth saying why it was not.
|
||||||
|
ihasmail's session is an httpOnly cookie against its own origin, and the only
|
||||||
|
other thing the API asks for is a fixed header that is not a secret. A
|
||||||
|
same-origin request from the service worker carries the cookie like any
|
||||||
|
other, so `Email/set` from a notification is an ordinary call. What the
|
||||||
|
worker genuinely cannot reach is anything a *tab* holds in memory — and the
|
||||||
|
API asks for none of it.
|
||||||
|
|
||||||
|
What it cannot reach is a catalogue. The worker is plain JavaScript outside
|
||||||
|
the bundle, with no i18n and no idea which mailbox is the archive, so the app
|
||||||
|
writes both down for it whenever the language, the account or the folder list
|
||||||
|
changes. Where there is no such note — between installing a new worker and
|
||||||
|
next opening ihasmail — the notification appears with no action buttons at
|
||||||
|
all rather than English ones over a guessed mailbox.
|
||||||
|
|
||||||
|
A session can still be gone by the time a button is pressed: expired, signed
|
||||||
|
out, or a cookie that did not outlive the browser. That comes back as a
|
||||||
|
refusal, and the notification says so rather than disappearing as though it
|
||||||
|
had worked. It does not open the app to recover — being interrupted is the
|
||||||
|
thing the button existed to avoid.
|
||||||
|
- **Share** — a message, or one attachment, handed to the operating system's
|
||||||
|
share sheet instead of to the filesystem. On a phone a download is close to a
|
||||||
|
dead end: the file lands in Downloads and whoever wanted to send it somewhere
|
||||||
|
goes hunting for it in a file manager. The sheet is on the message menu, on
|
||||||
|
each attachment row, and in the file viewer, which is where an attachment is
|
||||||
|
already open. A message shares as text rather than as the `.eml` beside it,
|
||||||
|
because a share sheet is aimed at everything that is not a mail client and an
|
||||||
|
`.eml` in a chat app is an attachment nobody can open. Every one of those
|
||||||
|
controls is drawn only where the browser has Web Share -- absent on desktop
|
||||||
|
Linux and in Firefox -- and sharing a file is asked about separately from
|
||||||
|
sharing at all. Where the share cannot be made, the download it sits beside
|
||||||
|
happens instead, so the worst case costs a tap rather than the file.
|
||||||
- **`mailto:` handler** — registered from Settings › General for the browser
|
- **`mailto:` handler** — registered from Settings › General for the browser
|
||||||
(needs HTTPS; Safari does not support it), and declared in the manifest so an
|
(needs HTTPS; Safari does not support it), and declared in the manifest so an
|
||||||
installed ihasmail is offered by the operating system wherever something asks
|
installed ihasmail is offered by the operating system wherever something asks
|
||||||
@@ -1139,6 +1389,7 @@ costs something to get wrong is the one that assumes the machine is yours.
|
|||||||
| Idle sign-out | after 5 minutes | none |
|
| Idle sign-out | after 5 minutes | none |
|
||||||
| Kept on the computer | nothing | settings cache, recent addresses, username |
|
| Kept on the computer | nothing | settings cache, recent addresses, username |
|
||||||
| Background notifications | refused | available |
|
| Background notifications | refused | available |
|
||||||
|
| Administration | unavailable | available, if the role allows it |
|
||||||
|
|
||||||
Local storage is gated on that answer for **reads** as well as writes — a
|
Local storage is gated on that answer for **reads** as well as writes — a
|
||||||
machine trusted once still has residue, and honouring it would let a previous
|
machine trusted once still has residue, and honouring it would let a previous
|
||||||
@@ -1198,6 +1449,68 @@ Over Stalwart's own registry objects, so there is no administrator in the loop:
|
|||||||
credentials". Doing it properly means implementing OAuth; that is in
|
credentials". Doing it properly means implementing OAuth; that is in
|
||||||
[ROADMAP.md](ROADMAP.md).
|
[ROADMAP.md](ROADMAP.md).
|
||||||
|
|
||||||
|
## Checking a signature
|
||||||
|
|
||||||
|
A signed message says who signed it, and ihasmail checks whether that holds up.
|
||||||
|
This is S/MIME only, and it stops at reading: nothing here signs, encrypts or
|
||||||
|
decrypts anything.
|
||||||
|
|
||||||
|
**What it checks.** For a `multipart/signed` message carrying a PKCS#7
|
||||||
|
signature, the exact bytes of the signed part — headers included, canonicalised
|
||||||
|
to CRLF — are hashed and compared against the `messageDigest` the signature
|
||||||
|
covers, and the signature over the signed attributes is verified with WebCrypto
|
||||||
|
against the certificate travelling inside the message. RSA (PKCS#1 v1.5) and
|
||||||
|
ECDSA over P-256, P-384 and P-521 are supported, with SHA-256, SHA-384 or
|
||||||
|
SHA-512.
|
||||||
|
|
||||||
|
**What a check is allowed to claim, which is the whole design.** A browser has
|
||||||
|
no system trust store, and the certificate arrives inside the message, so anyone
|
||||||
|
can self-sign as anyone. On its own a verified signature proves only that
|
||||||
|
whoever wrote the message held the key attached to it — which is why ihasmail
|
||||||
|
never renders the bare word *verified*.
|
||||||
|
|
||||||
|
What makes it worth anything is remembering. The first signed message from an
|
||||||
|
address pins that certificate's fingerprint in your settings; later ones are
|
||||||
|
compared against it. That is trust on first use, and it needs no certificate
|
||||||
|
authority:
|
||||||
|
|
||||||
|
| what happened | what you see |
|
||||||
|
|---|---|
|
||||||
|
| first signed message from this address | *"Signed by X, seen here for the first time"* — grey, and deliberately not congratulatory |
|
||||||
|
| same certificate as before | *"the same signer as before"* — the only case that gets a tick |
|
||||||
|
| **different certificate than before** | **loud**: both names, and told to check by some other route |
|
||||||
|
| valid signature, certificate for a different address | **loud**: the signature is not for this sender |
|
||||||
|
| body changed after signing | **loud**: the signature does not check out |
|
||||||
|
| signed, but uncheckable | grey, and careful to say *could not check* rather than *did not check out* |
|
||||||
|
|
||||||
|
The pins live in the account's settings file rather than in the browser, so the
|
||||||
|
same correspondent is not greeted as new on every device — which is what trains
|
||||||
|
people to click past the one warning that matters. A pin records the message
|
||||||
|
that created it, so the message which established a signer keeps saying so
|
||||||
|
rather than appearing to be corroborated by itself. A signer that changed, one
|
||||||
|
whose certificate does not name the sender, or one already expired is never
|
||||||
|
pinned: writing an anomaly into the baseline would make every later message
|
||||||
|
agree with it.
|
||||||
|
|
||||||
|
**What it will not do.**
|
||||||
|
|
||||||
|
- **OpenPGP is not checked**, and says so by name rather than as an unknown
|
||||||
|
format. The signature does not carry the key, and ihasmail has nowhere to get
|
||||||
|
a correspondent's public key from — `x:PublicKey` holds the account's *own*
|
||||||
|
keys, and fetching from a keyserver or WKD would leak who you correspond with
|
||||||
|
to a third party, which is the exact thing the image proxy exists to prevent.
|
||||||
|
- **No chain of trust.** Nothing is validated against a certificate authority,
|
||||||
|
no CA bundle is shipped, and revocation is not checked. "Issued by" reports
|
||||||
|
what the certificate says, and a self-signed certificate says it issued
|
||||||
|
itself.
|
||||||
|
- **SHA-1 signatures are refused**, not reported as valid.
|
||||||
|
- **RSA-PSS is declined** rather than attempted, because guessing the salt
|
||||||
|
length wrong would report a good signature as bad — a worse thing to say than
|
||||||
|
"cannot check".
|
||||||
|
|
||||||
|
The verifier is a separate bundle chunk, loaded only when a message's structure
|
||||||
|
says it is signed, so reading ordinary mail costs nothing for any of this.
|
||||||
|
|
||||||
## Privacy by default
|
## Privacy by default
|
||||||
|
|
||||||
Remote images blocked, the proxy on, read receipts never automatic, no
|
Remote images blocked, the proxy on, read receipts never automatic, no
|
||||||
@@ -1260,6 +1573,7 @@ wizard, because either would be state.
|
|||||||
| `UPSTREAM_TIMEOUT` | `30000` | Milliseconds |
|
| `UPSTREAM_TIMEOUT` | `30000` | Milliseconds |
|
||||||
| `MAX_UPLOAD_BYTES` | `52428800` | 50 MB |
|
| `MAX_UPLOAD_BYTES` | `52428800` | 50 MB |
|
||||||
| `IMAGE_PROXY` | `1` | Privacy proxy for remote images |
|
| `IMAGE_PROXY` | `1` | Privacy proxy for remote images |
|
||||||
|
| `ADMINISTRATION` | `1` | Offer in-app administration to accounts whose Stalwart role allows it; `0` turns it off, in the proxy as well as the menu |
|
||||||
| `LOGIN_RATE_LIMIT` | `10` | Attempts per window |
|
| `LOGIN_RATE_LIMIT` | `10` | Attempts per window |
|
||||||
| `COOKIE_NAME` | `ihm_session` | |
|
| `COOKIE_NAME` | `ihm_session` | |
|
||||||
| `APP_NAME` | `ihasmail` | Branding |
|
| `APP_NAME` | `ihasmail` | Branding |
|
||||||
@@ -1345,6 +1659,13 @@ moves an occurrence renumbering the ids around it. Two switches:
|
|||||||
`MOCK_NO_REGISTRY=1` omits the Stalwart capability so the sign-in refusal can be
|
`MOCK_NO_REGISTRY=1` omits the Stalwart capability so the sign-in refusal can be
|
||||||
tested.
|
tested.
|
||||||
|
|
||||||
|
Administration works against it too, with a directory of about thirty accounts,
|
||||||
|
three domains with their DKIM keys and zone files, behind the same permission
|
||||||
|
names Stalwart uses. `MOCK_ROLE` decides who the
|
||||||
|
demo user is: `admin` (the default), `tenant-admin`, `helpdesk` — a custom role
|
||||||
|
that may view and edit accounts but not create or delete them — or `user`, who
|
||||||
|
is not offered the menu at all.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# What it does not do
|
# What it does not do
|
||||||
|
|||||||
+55
-10
@@ -4,14 +4,29 @@ What was checked, against which server, and when. For a failure you are hitting
|
|||||||
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
|
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
|
||||||
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
|
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
|
||||||
|
|
||||||
The live instance runs **0.16.20**, upgraded from 0.16.19 on 2026-08-31 with
|
The live instance runs **0.16.22**, and as of **2026-08-26 there is nothing
|
||||||
eight seconds of downtime, and as of **2026-08-26 there is nothing left
|
left pending**. Most entries below were exercised against 0.16.19 on the date
|
||||||
pending**. Every entry below was exercised against 0.16.19 on the date it
|
they name, and the dates still say so: each upgrade since was read against the
|
||||||
names, and the dates still say so: the upgrade was read against the
|
diff rather than re-run, and nothing in those diffs touches the session
|
||||||
0.16.19→0.16.20 diff rather than re-run, and nothing in it touches the session
|
|
||||||
capabilities, blob, quota, submission or registry paths these entries describe.
|
capabilities, blob, quota, submission or registry paths these entries describe.
|
||||||
The calendar entries below carrying a 2026-08-31 date are the exception: those
|
The calendar entries carrying a 2026-08-31 date were exercised against a live
|
||||||
were exercised against the live 0.16.20 directly.
|
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. 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
|
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
|
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.
|
to work, and what ihasmail deliberately does not do.
|
||||||
@@ -27,6 +42,24 @@ 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
|
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).
|
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||||
|
|
||||||
|
- **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 parenthesised 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.
|
||||||
|
|
||||||
|
- **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 recognised 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 localise 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.
|
- **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 localise 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 catalogues 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 catalogue 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 catalogue 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.
|
- **`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 catalogues 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 catalogue 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 catalogue 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.
|
||||||
@@ -37,7 +70,19 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
|||||||
- **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.
|
- **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.
|
- **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.
|
- **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 now omits 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.
|
- **`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 grey 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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
@@ -45,12 +90,12 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
|||||||
- **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.
|
- **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.
|
- **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 honours 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, cancelled 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 cancelled 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.
|
- **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 honours 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, cancelled 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 cancelled 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). Cancelling 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.
|
- **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). Cancelling 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.
|
- **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 behaviour 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.
|
- **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 behaviour 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 is only true until the next write, and a stale one is wrong rather than invalid.** Stalwart's expanded-occurrence ids encode a position in the series, and writing a `recurrenceOverrides` entry adds a component that renumbers it. **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 points at another date, and a delete meant for one occurrence removes a different one. This is the second time the same shape of problem has cost a live debugging session, and it is worth saying plainly why it is dangerous: the failure is not a `notFound` a client would notice, it is a confident answer about the wrong day. ihasmail therefore never mutates an occurrence by an id it is holding. `recurrenceId` is the stable name for a slot in a series — it is the date — so `updateEvent` and `destroyEvent` look the current id up by it immediately before they act, and refuse outright if the date is no longer in the series rather than falling back to the id in hand. The mock renumbers too, by a different permutation to the real server's but with the property that matters, since a mock that kept ids stable would agree with precisely the belief that is wrong.
|
- **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 defence 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 behaviour, 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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ other people that ships inside it and the terms it comes under.
|
|||||||
|
|
||||||
## Colour palettes
|
## Colour palettes
|
||||||
|
|
||||||
Four of the palettes offered in Settings › Appearance are the work of their own
|
Ten of the palettes offered in Settings › Appearance are the work of their own
|
||||||
projects and are used under the MIT licence. Only the published colour values
|
projects and are used under the MIT licence. Only the published colour values
|
||||||
are used — no code, and nothing from anyone else's reimplementation of them.
|
are used — no code, and nothing from anyone else's reimplementation of them.
|
||||||
The values as fetched from each project are recorded in
|
The values as fetched from each project are recorded in
|
||||||
@@ -35,9 +35,47 @@ Licensed under the MIT licence. The light variant is "Dawn".
|
|||||||
Copyright (c) 2019 enkia — https://github.com/enkia/tokyo-night-vscode-theme
|
Copyright (c) 2019 enkia — https://github.com/enkia/tokyo-night-vscode-theme
|
||||||
Licensed under the MIT licence. The light variant is "Day".
|
Licensed under the MIT licence. The light variant is "Day".
|
||||||
|
|
||||||
|
### Catppuccin
|
||||||
|
|
||||||
|
Copyright (c) 2021 Catppuccin — https://github.com/catppuccin/palette
|
||||||
|
Licensed under the MIT licence. "Mocha" is the dark variant and "Latte" the
|
||||||
|
light one; both are published in that repository's palette.json.
|
||||||
|
|
||||||
|
### Solarized
|
||||||
|
|
||||||
|
Copyright (c) 2011 Ethan Schoonover — https://github.com/altercation/solarized
|
||||||
|
Licensed under the MIT licence. Light and dark are both original to it, and
|
||||||
|
share one set of accent values by design.
|
||||||
|
|
||||||
|
### Ayu
|
||||||
|
|
||||||
|
Copyright (c) Konstantin Pschera — https://github.com/ayu-theme/ayu-colors
|
||||||
|
Licensed under the MIT licence. The two signature accent colours come from the
|
||||||
|
same author's ayu-theme/vscode-ayu, also MIT.
|
||||||
|
|
||||||
|
### Kanagawa
|
||||||
|
|
||||||
|
Copyright (c) 2021 Tommaso Laurenzi — https://github.com/rebelot/kanagawa.nvim
|
||||||
|
Licensed under the MIT licence. "Wave" is the dark variant and "Lotus" the
|
||||||
|
light one. The theme takes its name from Hokusai's print.
|
||||||
|
|
||||||
|
### Everforest
|
||||||
|
|
||||||
|
Copyright (c) 2019 Sainnhe Park — https://github.com/sainnhe/everforest
|
||||||
|
Licensed under the MIT licence. The medium-contrast variant of each mode is
|
||||||
|
the one used here.
|
||||||
|
|
||||||
|
### Primer
|
||||||
|
|
||||||
|
Copyright (c) GitHub, Inc. — https://github.com/primer/primitives
|
||||||
|
Licensed under the MIT licence, which covers the colour values. "GitHub" and
|
||||||
|
the Invertocat logo are trademarks of GitHub, Inc.; this palette is named
|
||||||
|
"Primer" after the design system and is neither affiliated with nor endorsed
|
||||||
|
by GitHub.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
The MIT licence, under which all four are used:
|
The MIT licence, under which all ten are used:
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a
|
Permission is hereby granted, free of charge, to any person obtaining a
|
||||||
copy of this software and associated documentation files (the "Software"),
|
copy of this software and associated documentation files (the "Software"),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="LICENSE"><img alt="Licence: AGPL-3.0-or-later" src="https://img.shields.io/badge/licence-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
|
<a href="LICENSE"><img alt="Licence: AGPL-3.0-or-later" src="https://img.shields.io/badge/licence-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.20" src="https://img.shields.io/badge/Stalwart-0.16.20-6366f1?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://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>
|
<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>
|
||||||
@@ -33,6 +33,29 @@ durable belongs to Stalwart; the container is disposable.
|
|||||||
| 🧪 **[KNOWN-ISSUES.md](KNOWN-ISSUES.md)** | What was verified live, and where Stalwart departs from a spec |
|
| 🧪 **[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 |
|
| 🛣 **[ROADMAP.md](ROADMAP.md)** | What ihasmail does not do, and why |
|
||||||
|
|
||||||
|
### Companion tools
|
||||||
|
|
||||||
|
Two tools for getting a Stalwart server ready for ihasmail, one for each place
|
||||||
|
you might be starting from:
|
||||||
|
|
||||||
|
| | Starting from | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 🚀 **[ihasmail-oneshot](https://github.com/Coffey-Labs/ihasmail-oneshot)** | **Nothing** — a fresh Linux host with Docker | One command deploys a new Stalwart and a new ihasmail on a single host, already linked: certificates for both, the first mailboxes, and the DNS records to publish. Or `--local` for a loopback-only pair to try it |
|
||||||
|
| ⬆️ **[stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator)** | **An existing Stalwart 0.15.5** server | Upgrades it in place to the 0.16 series ihasmail requires, checkpointing every phase so an interrupted run resumes, and validating the server afterwards. Take a snapshot first: it does not undo a migration |
|
||||||
|
|
||||||
|
> **Releases are weekly, so `latest` normally lags `main`.** Automation builds
|
||||||
|
> and publishes the GHCR image every **Monday at 09:00 UTC**, in a week that had
|
||||||
|
> changes. Between one Monday and the next, `main` is ahead of the newest image
|
||||||
|
> — a fix merged on Tuesday is a `docker pull` away only after the following
|
||||||
|
> Monday. GitHub runs scheduled workflows on a best-effort basis, so treat the
|
||||||
|
> hour as approximate.
|
||||||
|
>
|
||||||
|
> This is worth knowing when a closed issue says a fix is *live*: that means the
|
||||||
|
> QA webmail server, which deploys from `main`, and not the image you have. If
|
||||||
|
> you want a change before the next Monday, build from `main` — see
|
||||||
|
> [Container images](#container-images). Otherwise pull after it, and the dated
|
||||||
|
> tag tells you exactly which build you are on.
|
||||||
|
|
||||||
This file is for people working *on* ihasmail. Everything about running it
|
This file is for people working *on* ihasmail. Everything about running it
|
||||||
lives in the docs.
|
lives in the docs.
|
||||||
|
|
||||||
@@ -54,10 +77,13 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
|
|||||||
- **Calendar** — JMAP Calendars / JSCalendar: month/week/day/agenda, recurrence, attendees and free-busy, colour categories
|
- **Calendar** — JMAP Calendars / JSCalendar: month/week/day/agenda, recurrence, attendees and free-busy, colour categories
|
||||||
- **Contacts** — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export
|
- **Contacts** — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export
|
||||||
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete
|
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete
|
||||||
- **Settings that follow the account**, not the browser — kept in a `settings.json` in the account's own JMAP Files, so ihasmail itself stays stateless
|
- **Signature checking** — S/MIME signed mail is verified as you read it, and the signer is remembered: a later message from the same address signed by somebody else is called out loudly. No certificate authority is involved and none is bundled, so ihasmail never claims more than it can show — see [Checking a signature](FEATURES.md#checking-a-signature)
|
||||||
|
- **Settings that follow the account**, not the browser — kept in a `settings.json` in the account's own JMAP Files. The format is ihasmail's; the file is the account's, under its quota, and outlives any container that read it. ihasmail holds none of it
|
||||||
- **Runs read-only** — one optional write path, and with it switched off the container needs no volume and no writable root. `IMMUTABLE=1` is checked at startup rather than trusted, so a half-applied switch refuses to boot instead of failing quietly. See [Running immutably](#running-immutably)
|
- **Runs read-only** — one optional write path, and with it switched off the container needs no volume and no writable root. `IMMUTABLE=1` is checked at startup rather than trusted, so a half-applied switch refuses to boot instead of failing quietly. See [Running immutably](#running-immutably)
|
||||||
- **Nine new interface languages** — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, alongside English and separate from the date-and-time locale. Every one is marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong
|
- **Nine new interface languages** — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, alongside English and separate from the date-and-time locale. Every one is marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong
|
||||||
|
- **Twelve themes** — Classic and ihasmail's own, plus Catppuccin, Dracula, Gruvbox, Rosé Pine, Tokyo Night, Solarized, Ayu, Kanagawa, Everforest and Primer, each with the light and dark half its own project publishes. Palette and light-or-dark are separate choices, and the accent colour still sits on top of any of them. Only published colour values are used, taken from each project's own repository; the shades between them are derived and every text colour is measured against the surface it sits on, so a palette that would not meet the contrast this app claims is not written at all — see [Themes](FEATURES.md#themes)
|
||||||
- **On a phone** — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation
|
- **On a phone** — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation
|
||||||
|
- **Administration** — for an account whose Stalwart role manages accounts or domains, from the account menu: create, edit and delete accounts and set their passwords; add domains, copy their DNS records one at a time or as a zone file, see their DKIM keys, and remove them once nothing uses them. Each control is there only when the role allows it, and Stalwart decides every call. Only for a session signed in with *This is my own device* ticked, and `ADMINISTRATION=0` turns it off for everyone — see [Administration](FEATURES.md#administration)
|
||||||
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
|
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
|
||||||
|
|
||||||
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
|
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
|
||||||
@@ -72,11 +98,36 @@ wrong guess had somewhere to fall back to, so it failed *quietly* — and that
|
|||||||
reached production. With one supported generation a wrong guess is a loud error
|
reached production. With one supported generation a wrong guess is a loud error
|
||||||
on the first call.
|
on the first call.
|
||||||
|
|
||||||
|
**Validated against 0.16.22**, released 13 September 2026: the live instance
|
||||||
|
runs it and the app has been tested against it. Four of its JMAP changes are
|
||||||
|
visible to a client, all in calendars and contacts:
|
||||||
|
`CalendarEvent/get` returns `baseEventId` only for a synthetic id, so an event
|
||||||
|
read by its stored id now carries `null` there rather than its own id; it
|
||||||
|
returns `null` for `recurrenceRule` and `recurrenceOverrides` asked for on a
|
||||||
|
synthetic id; `useDefaultAlerts` is stored per user and reads `false` when never
|
||||||
|
set; and `CalendarEvent/get` and `ContactCard/get` return only `id` for an empty
|
||||||
|
`properties` list, rather than everything. The mock reproduces all four.
|
||||||
|
|
||||||
|
Before it, **0.16.21**, released 6 September 2026: the app was run against a
|
||||||
|
real instance of it and the mail, calendar and contacts paths were exercised by
|
||||||
|
hand. Four of that release's JMAP changes are visible to a client
|
||||||
|
— an occurrence of a recurring event is now identified by its recurrence id
|
||||||
|
rather than by its position in the series, so an id held across a write no
|
||||||
|
longer silently 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 those four.
|
||||||
|
|
||||||
- Still on 0.15? 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).
|
- Still on 0.15? 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).
|
||||||
- Upgrading? [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
|
- Upgrading? [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
|
||||||
|
|
||||||
## Quick start (Docker)
|
## Quick start (Docker)
|
||||||
|
|
||||||
|
No Stalwart yet? [ihasmail-oneshot](https://github.com/Coffey-Labs/ihasmail-oneshot)
|
||||||
|
sets up both on one host in a single command. The steps below are for pointing
|
||||||
|
ihasmail at a Stalwart you already run.
|
||||||
|
|
||||||
```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: STALWART_URL=https://mail.example.com and APP_SECRET=$(openssl rand -base64 48)
|
||||||
@@ -96,7 +147,9 @@ Full instructions, TLS, and every environment variable:
|
|||||||
|
|
||||||
### Container images
|
### Container images
|
||||||
|
|
||||||
Published to GHCR on every release, for `linux/amd64` and `linux/arm64`:
|
Published to GHCR on every release, for `linux/amd64` and `linux/arm64`.
|
||||||
|
Releases are cut weekly — Mondays, 09:00 UTC, in a week that had changes — so
|
||||||
|
the newest image is normally behind `main`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/coffey-labs/ihasmail:latest
|
docker pull ghcr.io/coffey-labs/ihasmail:latest
|
||||||
@@ -319,7 +372,7 @@ missing.
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Requirements: Node ≥ 20.10 (22 recommended), npm ≥ 10.
|
Requirements: Node ≥ 20.19 (26 recommended), npm ≥ 10.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
@@ -346,9 +399,18 @@ 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: `urn:stalwart:jmap` 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 Stalwart's vocabulary rather than
|
||||||
RFC 8984's. Two switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
|
RFC 8984's. Four switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
|
||||||
and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so
|
and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so
|
||||||
the sign-in refusal can be tested.
|
the sign-in refusal can be tested; and `MOCK_NO_SCHEDULING_SEND=1` refuses a
|
||||||
|
calendar write that asks for scheduling messages, the way an account without
|
||||||
|
that permission is refused; and `MOCK_ROLE` decides who the demo user is for
|
||||||
|
Administration — `admin` (the default), `tenant-admin`, `helpdesk` or `user`.
|
||||||
|
|
||||||
|
It tracks the current release rather than 0.16 in general, and each behaviour
|
||||||
|
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 a client can
|
||||||
|
see, the mock changes with it, and the test that pinned the old behaviour is
|
||||||
|
rewritten rather than deleted, so the reversal stays on the record.
|
||||||
|
|
||||||
### Version numbers
|
### Version numbers
|
||||||
|
|
||||||
|
|||||||
+16
@@ -8,6 +8,7 @@ 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.
|
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
|
||||||
|
|
||||||
|
- **Administration beyond accounts and domains.** The Administration menu manages accounts and domains today — see [FEATURES.md](FEATURES.md#administration). Groups, mailing lists, roles, DNS and ACME providers and tenants 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. Reporting, queues, logs and 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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
@@ -15,3 +16,18 @@ See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about
|
|||||||
- **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.
|
- **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 catalogue without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
|
- **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 catalogue 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.
|
- **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 weaponised 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 defence, 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.
|
||||||
|
|||||||
+17
-1
@@ -214,7 +214,23 @@ prune_old_images() {
|
|||||||
printf '%s\n' "$stale" | xargs -r docker rmi >/dev/null 2>&1 || true
|
printf '%s\n' "$stale" | xargs -r docker rmi >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
VERSION="$(node scripts/version.mjs)"
|
# The version is the same sum scripts/version.mjs does -- the commit's own
|
||||||
|
# date, plus the pull request it arrived through or its short SHA -- done here
|
||||||
|
# in shell because a host that only runs containers has git and docker and no
|
||||||
|
# node. Given IHASMAIL_VERSION, use it as given, as the script would.
|
||||||
|
version_from_git() {
|
||||||
|
local date subject sha y m d
|
||||||
|
date="$(git show -s --format=%cs HEAD)"
|
||||||
|
subject="$(git show -s --format=%s HEAD)"
|
||||||
|
sha="$(git rev-parse --short HEAD)"
|
||||||
|
IFS=- read -r y m d <<<"$date"
|
||||||
|
if [[ "$subject" =~ ^Merge\ pull\ request\ \#([0-9]+) ]]; then
|
||||||
|
printf '%d.%d.%d+pr%s\n' "$((10#$y))" "$((10#$m))" "$((10#$d))" "${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
printf '%d.%d.%d+g%s\n' "$((10#$y))" "$((10#$m))" "$((10#$d))" "$sha"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
VERSION="${IHASMAIL_VERSION:-$(version_from_git)}"
|
||||||
# A Docker tag may not contain "+", and every version has one now:
|
# A Docker tag may not contain "+", and every version has one now:
|
||||||
# 2026.8.30+pr129, or +g1fa6578 for a commit that did not come through a pull
|
# 2026.8.30+pr129, or +g1fa6578 for a commit that did not come through a pull
|
||||||
# request. The image is tagged with the "+" turned into "-"; what the build is
|
# request. The image is tagged with the "+" turned into "-"; what the build is
|
||||||
|
|||||||
@@ -6,6 +6,31 @@ server {
|
|||||||
|
|
||||||
client_max_body_size 60m;
|
client_max_body_size 60m;
|
||||||
|
|
||||||
|
# Compression. The bundle is the bulk of first load -- about 933 KB
|
||||||
|
# uncompressed against 311 KB gzipped -- and nginx passes through anything
|
||||||
|
# the upstream already encoded rather than re-encoding it, so this is
|
||||||
|
# correct whether or not ihasmail compresses on its own.
|
||||||
|
#
|
||||||
|
# text/event-stream is deliberately absent from gzip_types: the push stream
|
||||||
|
# must not be compressed or buffered, which is also why proxy_buffering is
|
||||||
|
# off below.
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 5;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
# text/javascript is listed explicitly: ihasmail serves scripts with that
|
||||||
|
# type rather than application/javascript, so a conventional gzip_types
|
||||||
|
# list compresses the stylesheet and leaves the largest asset alone.
|
||||||
|
gzip_types
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/manifest+json
|
||||||
|
image/svg+xml
|
||||||
|
text/css
|
||||||
|
text/javascript
|
||||||
|
text/plain;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
Generated
+1105
-1708
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -10,7 +10,7 @@
|
|||||||
"web"
|
"web"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.10"
|
"node": ">=20.19"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -n server,web -c blue,magenta \"npm run dev -w server\" \"npm run dev -w web\"",
|
"dev": "concurrently -n server,web -c blue,magenta \"npm run dev -w server\" \"npm run dev -w web\"",
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^7.0.2",
|
||||||
|
"typescript-ast": "npm:typescript@^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,90 @@ SOURCES = {
|
|||||||
q1="#006c86", q2="#385f0d", q3="#65359d",
|
q1="#006c86", q2="#385f0d", q3="#65359d",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
"catppuccin": {
|
||||||
|
"dark": dict( # Mocha
|
||||||
|
bg="#1e1e2e", elev="#313244", sunken="#181825", line="#45475a",
|
||||||
|
fg="#cdd6f4", muted="#a6adc8", accent="#cba6f7", link="#89b4fa",
|
||||||
|
danger="#f38ba8", warn="#fab387", success="#a6e3a1", star="#f9e2af",
|
||||||
|
q1="#89b4fa", q2="#a6e3a1", q3="#f5c2e7",
|
||||||
|
),
|
||||||
|
"light": dict( # Latte
|
||||||
|
bg="#e6e9ef", elev="#eff1f5", sunken="#dce0e8", line="#ccd0da",
|
||||||
|
fg="#4c4f69", muted="#6c6f85", accent="#8839ef", link="#1e66f5",
|
||||||
|
danger="#d20f39", warn="#fe640b", success="#40a02b", star="#df8e1d",
|
||||||
|
q1="#1e66f5", q2="#40a02b", q3="#ea76cb",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"solarized": {
|
||||||
|
"dark": dict(
|
||||||
|
bg="#002b36", elev="#073642", sunken="#001f28", line="#0d4552",
|
||||||
|
fg="#839496", muted="#586e75", accent="#268bd2", link="#2aa198",
|
||||||
|
danger="#dc322f", warn="#cb4b16", success="#859900", star="#b58900",
|
||||||
|
q1="#2aa198", q2="#859900", q3="#6c71c4",
|
||||||
|
),
|
||||||
|
"light": dict(
|
||||||
|
bg="#fdf6e3", elev="#fffdf6", sunken="#eee8d5", line="#e6dfc8",
|
||||||
|
fg="#657b83", muted="#93a1a1", accent="#268bd2", link="#2aa198",
|
||||||
|
danger="#dc322f", warn="#cb4b16", success="#859900", star="#b58900",
|
||||||
|
q1="#2aa198", q2="#859900", q3="#6c71c4",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"ayu": {
|
||||||
|
"dark": dict(
|
||||||
|
bg="#0d1017", elev="#10141c", sunken="#070a0f", line="#1b1f29",
|
||||||
|
fg="#bfbdb6", muted="#5a6378", accent="#e6b450", link="#59c2ff",
|
||||||
|
danger="#f07178", warn="#ff8f40", success="#aad94c", star="#ffb454",
|
||||||
|
q1="#39bae6", q2="#aad94c", q3="#d2a6ff",
|
||||||
|
),
|
||||||
|
"light": dict(
|
||||||
|
bg="#f8f9fa", elev="#fcfcfc", sunken="#ebeef0", line="#dfe2e5",
|
||||||
|
fg="#5c6166", muted="#828e9f", accent="#f29718", link="#22a4e6",
|
||||||
|
danger="#f07171", warn="#fa8532", success="#86b300", star="#eba400",
|
||||||
|
q1="#55b4d4", q2="#86b300", q3="#a37acc",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"kanagawa": {
|
||||||
|
"dark": dict( # Wave
|
||||||
|
bg="#1f1f28", elev="#2a2a37", sunken="#16161d", line="#363646",
|
||||||
|
fg="#dcd7ba", muted="#727169", accent="#7e9cd8", link="#7fb4ca",
|
||||||
|
danger="#e82424", warn="#ff9e3b", success="#98bb6c", star="#e6c384",
|
||||||
|
q1="#7fb4ca", q2="#98bb6c", q3="#d27e99",
|
||||||
|
),
|
||||||
|
"light": dict( # Lotus
|
||||||
|
bg="#e5ddb0", elev="#f2ecbc", sunken="#dcd5ac", line="#d5cea3",
|
||||||
|
fg="#545464", muted="#716e61", accent="#624c83", link="#4d699b",
|
||||||
|
danger="#c84053", warn="#cc6d00", success="#6f894e", star="#77713f",
|
||||||
|
q1="#4d699b", q2="#6f894e", q3="#b35b79",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"everforest": {
|
||||||
|
"dark": dict( # medium
|
||||||
|
bg="#2d353b", elev="#343f44", sunken="#232a2e", line="#475258",
|
||||||
|
fg="#d3c6aa", muted="#859289", accent="#a7c080", link="#7fbbb3",
|
||||||
|
danger="#e67e80", warn="#e69875", success="#a7c080", star="#dbbc7f",
|
||||||
|
q1="#7fbbb3", q2="#a7c080", q3="#d699b6",
|
||||||
|
),
|
||||||
|
"light": dict( # medium
|
||||||
|
bg="#efebd4", elev="#fdf6e3", sunken="#e6e2cc", line="#bdc3af",
|
||||||
|
fg="#5c6a72", muted="#939f91", accent="#8da101", link="#3a94c5",
|
||||||
|
danger="#f85552", warn="#f57d26", success="#8da101", star="#dfa000",
|
||||||
|
q1="#3a94c5", q2="#8da101", q3="#df69ba",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"primer": {
|
||||||
|
"dark": dict(
|
||||||
|
bg="#0d1117", elev="#151b23", sunken="#010409", line="#3d444d",
|
||||||
|
fg="#f0f6fc", muted="#9198a1", accent="#58a6ff", link="#79c0ff",
|
||||||
|
danger="#ff7b72", warn="#e3b341", success="#3fb950", star="#d29922",
|
||||||
|
q1="#79c0ff", q2="#56d364", q3="#d2a8ff",
|
||||||
|
),
|
||||||
|
"light": dict(
|
||||||
|
bg="#f6f8fa", elev="#ffffff", sunken="#eff2f5", line="#d1d9e0",
|
||||||
|
fg="#25292e", muted="#59636e", accent="#0969da", link="#0550ae",
|
||||||
|
danger="#cf222e", warn="#9a6700", success="#1a7f37", star="#bf8700",
|
||||||
|
q1="#0550ae", q2="#116329", q3="#8250df",
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# What each token has to clear, and against which surface. Normal text is 4.5;
|
# What each token has to clear, and against which surface. Normal text is 4.5;
|
||||||
@@ -183,6 +267,15 @@ def build(pid: str, mode: str, src: dict[str, str]) -> tuple[dict[str, str], lis
|
|||||||
notes.append(f"{name} {colour} -> {out} ({contrast(colour, bg):.2f} -> {contrast(out, bg):.2f})")
|
notes.append(f"{name} {colour} -> {out} ({contrast(colour, bg):.2f} -> {contrast(out, bg):.2f})")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
# Body text is lifted like every other text tone rather than exempted.
|
||||||
|
# Most of these palettes publish a body colour around 4.5:1 -- their own
|
||||||
|
# target -- and ihasmail asks 7:1 of the text a reader looks at all day.
|
||||||
|
# Rejecting a palette over that would have cost five of the six added in
|
||||||
|
# 2026-09; nudging the published colour along its own hue costs nothing a
|
||||||
|
# reader can name, and the shift is recorded in the header of the
|
||||||
|
# generated block like every other one.
|
||||||
|
fg = lift("fg", fg, TEXT_ON_BG["fg"])
|
||||||
|
|
||||||
muted = lift("muted", src["muted"], TEXT_ON_BG["muted"])
|
muted = lift("muted", src["muted"], TEXT_ON_BG["muted"])
|
||||||
# Between muted and the background, but still readable: this is timestamps
|
# Between muted and the background, but still readable: this is timestamps
|
||||||
# and counts, which are small and still prose.
|
# and counts, which are small and still prose.
|
||||||
|
|||||||
@@ -12,20 +12,66 @@
|
|||||||
* in the file looking correct, is never looked up, and the app renders English
|
* in the file looking correct, is never looked up, and the app renders English
|
||||||
* for ever. Nothing warns, because a catalogue is only ever read by key.
|
* for ever. Nothing warns, because a catalogue is only ever read by key.
|
||||||
*/
|
*/
|
||||||
import ts from "typescript";
|
/*
|
||||||
|
* The parser, not the compiler.
|
||||||
|
*
|
||||||
|
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
|
||||||
|
* binary and nothing else, so `typescript` now exports `version` and
|
||||||
|
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
|
||||||
|
* in this directory started throwing "Cannot read properties of undefined
|
||||||
|
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
|
||||||
|
* workflow runs these.
|
||||||
|
*
|
||||||
|
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
|
||||||
|
* API (see package.json). It parses; `typescript` still type-checks and builds.
|
||||||
|
* Two entries, two jobs -- not a version someone forgot to remove.
|
||||||
|
*/
|
||||||
|
import ts from "typescript-ast";
|
||||||
import { readFileSync, globSync } from "node:fs";
|
import { readFileSync, globSync } from "node:fs";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Two sets, because there are two questions and they need different nets.
|
||||||
|
*
|
||||||
|
* `wanted` is what a catalogue *owes*: the strings that actually reach t(),
|
||||||
|
* tc() or plural(). Coverage is measured against it, so it has to stay strict
|
||||||
|
* -- widening it would count every CSS class and JMAP method name as an
|
||||||
|
* untranslated string.
|
||||||
|
*
|
||||||
|
* `seen` is every string literal in the source, and answers only "is this
|
||||||
|
* catalogue key still written down anywhere". Stale detection needs the wide
|
||||||
|
* net: a key reaches t() as a variable often enough that a strict set reports
|
||||||
|
* mostly false alarms.
|
||||||
|
*/
|
||||||
const wanted = new Set();
|
const wanted = new Set();
|
||||||
|
const seen = new Set();
|
||||||
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) {
|
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) {
|
||||||
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||||
const visit = (n) => {
|
const visit = (n) => {
|
||||||
/*
|
/*
|
||||||
* Labels held in a constant and translated where they render -- t(s.label)
|
* Anything held in a constant and translated where it renders -- t(s.label),
|
||||||
* -- reach t() as a variable, so there is no literal for this to find and
|
* t(b.description), t(group) -- reaches t() as a variable, so there is no
|
||||||
* every one of them looked "stale". They are collected from the constants
|
* literal at the call site and every one of them looked "stale".
|
||||||
* instead: a `label:` property, or a value in an object of them. Without
|
*
|
||||||
* this the stale check cried wolf 33 times and would have been switched
|
* This used to chase the shapes one at a time: a `label:` property, then an
|
||||||
* off, which is the only outcome worse than not having it.
|
* object named *_LABELS. It still cried wolf, because the shapes kept
|
||||||
|
* coming -- `description:` and `group:` on keyboard bindings, the calendar's
|
||||||
|
* view names, the read-receipt refusals, the palette names. 41 reported,
|
||||||
|
* 10 of them real. A report that is three-quarters false is one nobody acts
|
||||||
|
* on, which is how these sat unread long enough to be worth a commit of
|
||||||
|
* their own.
|
||||||
|
*
|
||||||
|
* So: any string literal anywhere in the source counts as a use. That
|
||||||
|
* under-reports -- a literal that exists but is never passed to t() will not
|
||||||
|
* be flagged -- and that is the right way round. A missed stale key costs a
|
||||||
|
* line of dead translation; a false one costs the credibility of the whole
|
||||||
|
* check, and then every real finding with it.
|
||||||
|
*/
|
||||||
|
if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) seen.add(n.text);
|
||||||
|
if (ts.isJsxText(n)) { const text = n.text.trim(); if (text) seen.add(text); }
|
||||||
|
/*
|
||||||
|
* A `label:` in a constant is still a string somebody has to translate --
|
||||||
|
* it reaches t() one render later -- so it stays part of what a catalogue
|
||||||
|
* owes, and out of coverage it would flatter the number.
|
||||||
*/
|
*/
|
||||||
if (ts.isPropertyAssignment(n) && n.name.getText(src) === "label" && ts.isStringLiteral(n.initializer)) wanted.add(n.initializer.text);
|
if (ts.isPropertyAssignment(n) && n.name.getText(src) === "label" && ts.isStringLiteral(n.initializer)) wanted.add(n.initializer.text);
|
||||||
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && /_LABELS?$/.test(n.name.text)) {
|
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && /_LABELS?$/.test(n.name.text)) {
|
||||||
@@ -44,6 +90,7 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
|
|||||||
// fallback, not a second obligation -- asking for both would report
|
// fallback, not a second obligation -- asking for both would report
|
||||||
// work that does not exist.
|
// work that does not exist.
|
||||||
wanted.add(`${a0.text}\u0004${n.arguments[1].text}`);
|
wanted.add(`${a0.text}\u0004${n.arguments[1].text}`);
|
||||||
|
seen.add(`${a0.text}\u0004${n.arguments[1].text}`);
|
||||||
}
|
}
|
||||||
if (fn === "plural" && n.arguments[1] && ts.isObjectLiteralExpression(n.arguments[1])) {
|
if (fn === "plural" && n.arguments[1] && ts.isObjectLiteralExpression(n.arguments[1])) {
|
||||||
for (const p of n.arguments[1].properties) {
|
for (const p of n.arguments[1].properties) {
|
||||||
@@ -91,15 +138,14 @@ for (const file of globSync("web/src/locales/*.ts")) {
|
|||||||
ts.forEachChild(n, visit);
|
ts.forEachChild(n, visit);
|
||||||
};
|
};
|
||||||
visit(src);
|
visit(src);
|
||||||
const stale = [...have].filter((k) => !wanted.has(k) && !["one", "other", "few", "many", "zero", "two"].includes(k));
|
const stale = [...have].filter((k) => !seen.has(k) && !["one", "other", "few", "many", "zero", "two"].includes(k));
|
||||||
const missing = [...wanted].filter((k) => !have.has(k));
|
const missing = [...wanted].filter((k) => !have.has(k));
|
||||||
const pct = Math.round(((wanted.size - missing.length) / wanted.size) * 100);
|
const pct = Math.round(((wanted.size - missing.length) / wanted.size) * 100);
|
||||||
console.log(`${tag}: ${wanted.size - missing.length}/${wanted.size} translated (${pct}%), ${missing.length} falling back to English`);
|
console.log(`${tag}: ${wanted.size - missing.length}/${wanted.size} translated (${pct}%), ${missing.length} falling back to English`);
|
||||||
if (stale.length) {
|
if (stale.length) {
|
||||||
failed = true;
|
failed = true;
|
||||||
console.log(`\n ${stale.length} STALE key(s) — translated but never looked up, so they do nothing:`);
|
console.log(`\n ${stale.length} STALE key(s) — translated but never looked up, so they do nothing:`);
|
||||||
for (const k of stale.slice(0, 25)) console.log(` ${JSON.stringify(k)}`);
|
for (const k of stale) console.log(` ${JSON.stringify(k)}`);
|
||||||
if (stale.length > 25) console.log(` …and ${stale.length - 25} more`);
|
|
||||||
}
|
}
|
||||||
if (process.argv.includes("--missing")) {
|
if (process.argv.includes("--missing")) {
|
||||||
console.log(`\n missing:`);
|
console.log(`\n missing:`);
|
||||||
|
|||||||
@@ -11,7 +11,21 @@
|
|||||||
* exits non-zero only with --check, so CI can be told to fail on regressions
|
* exits non-zero only with --check, so CI can be told to fail on regressions
|
||||||
* later, once the number is low enough for that to mean something.
|
* later, once the number is low enough for that to mean something.
|
||||||
*/
|
*/
|
||||||
import ts from "typescript";
|
/*
|
||||||
|
* The parser, not the compiler.
|
||||||
|
*
|
||||||
|
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
|
||||||
|
* binary and nothing else, so `typescript` now exports `version` and
|
||||||
|
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
|
||||||
|
* in this directory started throwing "Cannot read properties of undefined
|
||||||
|
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
|
||||||
|
* workflow runs these.
|
||||||
|
*
|
||||||
|
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
|
||||||
|
* API (see package.json). It parses; `typescript` still type-checks and builds.
|
||||||
|
* Two entries, two jobs -- not a version someone forgot to remove.
|
||||||
|
*/
|
||||||
|
import ts from "typescript-ast";
|
||||||
import { readFileSync, globSync } from "node:fs";
|
import { readFileSync, globSync } from "node:fs";
|
||||||
|
|
||||||
/** Attributes a person reads. `className` and `key` are not among them. */
|
/** Attributes a person reads. `className` and `key` are not among them. */
|
||||||
|
|||||||
@@ -12,7 +12,21 @@
|
|||||||
* node scripts/i18n-extract.mjs <file...> rewrite in place
|
* node scripts/i18n-extract.mjs <file...> rewrite in place
|
||||||
* node scripts/i18n-extract.mjs --dry <file...>
|
* node scripts/i18n-extract.mjs --dry <file...>
|
||||||
*/
|
*/
|
||||||
import ts from "typescript";
|
/*
|
||||||
|
* The parser, not the compiler.
|
||||||
|
*
|
||||||
|
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
|
||||||
|
* binary and nothing else, so `typescript` now exports `version` and
|
||||||
|
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
|
||||||
|
* in this directory started throwing "Cannot read properties of undefined
|
||||||
|
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
|
||||||
|
* workflow runs these.
|
||||||
|
*
|
||||||
|
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
|
||||||
|
* API (see package.json). It parses; `typescript` still type-checks and builds.
|
||||||
|
* Two entries, two jobs -- not a version someone forgot to remove.
|
||||||
|
*/
|
||||||
|
import ts from "typescript-ast";
|
||||||
import { readFileSync, writeFileSync } from "node:fs";
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "description"]);
|
const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hint", "confirmLabel", "description"]);
|
||||||
|
|||||||
@@ -18,7 +18,21 @@
|
|||||||
* string that is neither -- one no catalogue has a key for, which therefore
|
* string that is neither -- one no catalogue has a key for, which therefore
|
||||||
* cannot be translated at all, however many languages ship.
|
* cannot be translated at all, however many languages ship.
|
||||||
*/
|
*/
|
||||||
import ts from "typescript";
|
/*
|
||||||
|
* The parser, not the compiler.
|
||||||
|
*
|
||||||
|
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
|
||||||
|
* binary and nothing else, so `typescript` now exports `version` and
|
||||||
|
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
|
||||||
|
* in this directory started throwing "Cannot read properties of undefined
|
||||||
|
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
|
||||||
|
* workflow runs these.
|
||||||
|
*
|
||||||
|
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
|
||||||
|
* API (see package.json). It parses; `typescript` still type-checks and builds.
|
||||||
|
* Two entries, two jobs -- not a version someone forgot to remove.
|
||||||
|
*/
|
||||||
|
import ts from "typescript-ast";
|
||||||
import { readFileSync, globSync } from "node:fs";
|
import { readFileSync, globSync } from "node:fs";
|
||||||
|
|
||||||
/* Where a string literal in this position is shown to somebody. */
|
/* Where a string literal in this position is shown to somebody. */
|
||||||
|
|||||||
@@ -9,7 +9,21 @@
|
|||||||
* exists is dead weight, but a call with no key is an untranslated string
|
* exists is dead weight, but a call with no key is an untranslated string
|
||||||
* nobody noticed.
|
* nobody noticed.
|
||||||
*/
|
*/
|
||||||
import ts from "typescript";
|
/*
|
||||||
|
* The parser, not the compiler.
|
||||||
|
*
|
||||||
|
* TypeScript 7 is the native port: its package ships a `tsc` shim over a Go
|
||||||
|
* binary and nothing else, so `typescript` now exports `version` and
|
||||||
|
* `versionMajorMinor` and no compiler API at all. Every `ts.createSourceFile`
|
||||||
|
* in this directory started throwing "Cannot read properties of undefined
|
||||||
|
* (reading 'Latest')" the day the bump landed, and nothing noticed, because no
|
||||||
|
* workflow runs these.
|
||||||
|
*
|
||||||
|
* `typescript-ast` is an npm alias for the last TypeScript that carries the JS
|
||||||
|
* API (see package.json). It parses; `typescript` still type-checks and builds.
|
||||||
|
* Two entries, two jobs -- not a version someone forgot to remove.
|
||||||
|
*/
|
||||||
|
import ts from "typescript-ast";
|
||||||
import { readFileSync, globSync } from "node:fs";
|
import { readFileSync, globSync } from "node:fs";
|
||||||
|
|
||||||
const strings = new Set();
|
const strings = new Set();
|
||||||
|
|||||||
+5
-5
@@ -16,12 +16,12 @@
|
|||||||
"mock:no-keyword-sort": "MOCK_NO_KEYWORD_SORT=1 tsx src/mock/index.ts"
|
"mock:no-keyword-sort": "MOCK_NO_KEYWORD_SORT=1 tsx src/mock/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.8",
|
"@hono/node-server": "^2.1.1",
|
||||||
"hono": "^4.7.4"
|
"hono": "^4.13.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^26.5.1",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.23.13",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ test("an account with no locale set yields none, rather than a guess", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("neither answering leaves the locale unknown", () => {
|
test("neither answering leaves the locale unknown", () => {
|
||||||
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null });
|
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null, permissions: [] });
|
||||||
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null });
|
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null, permissions: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("locales that carry no language are dropped, not passed through", () => {
|
test("locales that carry no language are dropped, not passed through", () => {
|
||||||
@@ -48,7 +48,7 @@ test("a server without the registry is not asked for anything", async () => {
|
|||||||
// fails the whole request rather than the one call.
|
// fails the whole request rather than the one call.
|
||||||
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
|
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
|
||||||
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
|
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
|
||||||
assert.deepEqual(info, { locale: null, edition: null });
|
assert.deepEqual(info, { locale: null, edition: null, permissions: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("no capabilities at all is treated the same way", async () => {
|
test("no capabilities at all is treated the same way", async () => {
|
||||||
@@ -110,3 +110,32 @@ test("a shared account carrying the capability is enough to recognise the server
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With a domain mapped to its own Stalwart (#238), everything asked about the
|
||||||
|
* account has to go to that server. The locale lookup resolved Stalwart's
|
||||||
|
* `apiUrl` against the default server instead, so a mapped account's locale
|
||||||
|
* was requested from a server that had never heard of it.
|
||||||
|
*/
|
||||||
|
test("account info is asked of the server that issued the session", async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||||
|
seen.push(String(input instanceof Request ? input.url : input));
|
||||||
|
return new Response(JSON.stringify({ methodResponses: [], edition: "oss" }), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
}) as typeof fetch;
|
||||||
|
try {
|
||||||
|
const session = {
|
||||||
|
capabilities: baseCaps,
|
||||||
|
accounts: { a1: { accountCapabilities: { [STALWART]: {} } } },
|
||||||
|
primaryAccounts: { [STALWART]: "a1" },
|
||||||
|
apiUrl: "https://mail.mapped.test/jmap/",
|
||||||
|
baseUrl: "https://mail.mapped.test",
|
||||||
|
};
|
||||||
|
await getAccountInfo("session-mapped-domain", "Basic x", session as never);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
}
|
||||||
|
assert.ok(seen.length >= 2, "asks for both the locale and the edition");
|
||||||
|
for (const url of seen) assert.ok(url.startsWith("https://mail.mapped.test/"), `${url} went to the wrong server`);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { administrationAllowed, gateAdministration, grantsAdministration, mayNameRegistryMethod } from "./adminGate.js";
|
||||||
|
|
||||||
|
const req = (...methods: string[]) => JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: methods.map((m, i) => [m, {}, `c${i}`]) });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With ADMINISTRATION=0 an administrator's browser must not be a way round the
|
||||||
|
* operator's decision. Hiding the menu would leave the proxy forwarding the
|
||||||
|
* very calls the menu made.
|
||||||
|
*/
|
||||||
|
test("mail, calendars and the rest pass untouched", () => {
|
||||||
|
const r = gateAdministration(req("Email/query", "Mailbox/get", "CalendarEvent/set", "FileNode/get", "Principal/getAvailability"));
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the account's own registry objects pass", () => {
|
||||||
|
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/set", "x:PublicKey/get", "x:MaskedEmail/set")).ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("directory and server objects are refused, and named", () => {
|
||||||
|
for (const m of ["x:Account/get", "x:Domain/set", "x:Role/query", "x:Tenant/get", "x:SystemSettings/set", "x:DkimSignature/get"]) {
|
||||||
|
assert.deepEqual(gateAdministration(req("Email/get", m)), { ok: false, method: m });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a body that could name a registry method and cannot be read is refused rather than forwarded", () => {
|
||||||
|
assert.deepEqual(gateAdministration('{"methodCalls": [["x:Account/get"'), { ok: false, method: null });
|
||||||
|
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: "x:Account/get" })), { ok: false, method: null });
|
||||||
|
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: [[{}, {}, "c"]], note: "x:" })), { ok: false, method: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a body that cannot name a registry method is forwarded exactly as it came", () => {
|
||||||
|
// Most traffic from a session that may not administer: no parse, no rewrite.
|
||||||
|
const raw = '{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["Email/get",{"ids":["a"]},"c"]]}';
|
||||||
|
assert.equal(mayNameRegistryMethod(raw), false);
|
||||||
|
assert.deepEqual(gateAdministration(raw), { ok: true, body: raw });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a method name hidden behind a unicode escape is still found", () => {
|
||||||
|
// JSON.parse and the server both read \u0078 as "x"; a substring check alone would not.
|
||||||
|
const raw = '{"methodCalls":[["\\u0078:Account/get",{},"c"]]}';
|
||||||
|
assert.equal(mayNameRegistryMethod(raw), true);
|
||||||
|
assert.deepEqual(gateAdministration(raw), { ok: false, method: "x:Account/get" });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The operator's rule: administration only from a session signed in with
|
||||||
|
* "This is my own device" ticked, and never when the installation turned it off.
|
||||||
|
*/
|
||||||
|
test("administration needs both the installation and a device marked as the person's own", () => {
|
||||||
|
assert.equal(administrationAllowed(true, true), true);
|
||||||
|
assert.equal(administrationAllowed(true, false), false);
|
||||||
|
assert.equal(administrationAllowed(false, true), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an account counts as an administrator by the same test the menu makes", () => {
|
||||||
|
assert.equal(grantsAdministration(["sysAccountQuery", "sysAccountGet"]), true);
|
||||||
|
assert.equal(grantsAdministration(["sysDomainQuery", "sysDomainGet"]), true);
|
||||||
|
assert.equal(grantsAdministration(["sysAccountQuery", "sysDomainGet"]), false);
|
||||||
|
assert.equal(grantsAdministration(["jmapEmailGet", "sysAccountSettingsGet"]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("what is forwarded is what was checked", () => {
|
||||||
|
// A duplicate key is read one way by JSON.parse; forwarding the parsed form
|
||||||
|
// means the server cannot read it the other way.
|
||||||
|
const raw = '{"methodCalls":[["x:Account/get",{},"a"]],"methodCalls":[["Email/get",{},"b"]]}';
|
||||||
|
const r = gateAdministration(raw);
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
if (r.ok) assert.equal(r.body, JSON.stringify({ methodCalls: [["Email/get", {}, "b"]] }));
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* What the JMAP proxy lets through for a session that may not administer:
|
||||||
|
* the operator turned it off (`ADMINISTRATION=0`), or the session was signed
|
||||||
|
* in without "This is my own device".
|
||||||
|
*
|
||||||
|
* Hiding the menu is not turning it off. `/api/jmap` forwards any method the
|
||||||
|
* browser sends, and Stalwart's registry answers whatever the credential's role
|
||||||
|
* allows -- so without this, an administrator could still manage accounts, or
|
||||||
|
* the whole server, from the browser console of an installation whose operator
|
||||||
|
* said no. With it off, the proxy refuses every `x:` method except the few that
|
||||||
|
* are about the signed-in account itself.
|
||||||
|
*
|
||||||
|
* An allowlist rather than a list of administrative objects, because the
|
||||||
|
* registry has dozens of them -- listeners, stores, tracers, system settings --
|
||||||
|
* and a new release adds more. An object not named here is refused, which errs
|
||||||
|
* towards the operator's decision.
|
||||||
|
*
|
||||||
|
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
|
||||||
|
* touched: they act on what the account can already reach.
|
||||||
|
*/
|
||||||
|
const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "ApiKey", "PublicKey", "MaskedEmail"]);
|
||||||
|
|
||||||
|
export type GateResult = { ok: true; body: string } | { ok: false; method: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a session may administer at all: the installation allows it, and
|
||||||
|
* the person signing in said the device is their own.
|
||||||
|
*
|
||||||
|
* The second half is the operator's rule, not Stalwart's. A borrowed laptop or
|
||||||
|
* a library machine is exactly where a session should not be able to reset a
|
||||||
|
* password or remove a domain, and "This is my own device" is the one thing
|
||||||
|
* the sign-in form already asks that says where it is being used. An untrusted
|
||||||
|
* session is also signed out when idle and wipes its local data, so nothing
|
||||||
|
* about it suits an administrator's work.
|
||||||
|
*/
|
||||||
|
export function administrationAllowed(enabled: boolean, remember: boolean): boolean {
|
||||||
|
return enabled && remember;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an account's permissions would put Administration in its menu --
|
||||||
|
* the same test the client makes, so the server can say why it is missing
|
||||||
|
* without handing over the permissions themselves.
|
||||||
|
*/
|
||||||
|
export function grantsAdministration(permissions: readonly string[]): boolean {
|
||||||
|
const has = new Set(permissions);
|
||||||
|
return (has.has("sysAccountQuery") && has.has("sysAccountGet")) || (has.has("sysDomainQuery") && has.has("sysDomainGet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a body could hold a registry method name at all, so the common case
|
||||||
|
* -- mail, calendars, contacts from a session that may not administer -- skips
|
||||||
|
* the parse. A method name is a JSON string starting `x:`, which appears in the
|
||||||
|
* text as `"x:` unless written with a `\u` escape; a body with neither cannot
|
||||||
|
* contain one, and is forwarded exactly as it came.
|
||||||
|
*/
|
||||||
|
export function mayNameRegistryMethod(raw: string): boolean {
|
||||||
|
return raw.includes('"x:') || raw.includes("\\u");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a JMAP request body. On success, hands back the body to forward --
|
||||||
|
* serialised from what was inspected, so the server can never be sent
|
||||||
|
* something different from what was checked (a duplicate key, say, read one
|
||||||
|
* way here and another way there).
|
||||||
|
*/
|
||||||
|
export function gateAdministration(raw: string): GateResult {
|
||||||
|
if (!mayNameRegistryMethod(raw)) return { ok: true, body: raw };
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, method: null };
|
||||||
|
}
|
||||||
|
const calls = (parsed as { methodCalls?: unknown } | null)?.methodCalls;
|
||||||
|
if (!Array.isArray(calls)) return { ok: false, method: null };
|
||||||
|
for (const call of calls) {
|
||||||
|
const name = Array.isArray(call) ? call[0] : undefined;
|
||||||
|
if (typeof name !== "string") return { ok: false, method: null };
|
||||||
|
if (!name.startsWith("x:")) continue;
|
||||||
|
const object = name.slice(2).split("/")[0] ?? "";
|
||||||
|
if (!SELF_SERVICE.has(object)) return { ok: false, method: name };
|
||||||
|
}
|
||||||
|
return { ok: true, body: JSON.stringify(parsed) };
|
||||||
|
}
|
||||||
+255
-8
@@ -1,8 +1,14 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { Context, MiddlewareHandler } from "hono";
|
import type { Context, MiddlewareHandler } from "hono";
|
||||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
||||||
|
import { compress } from "hono/compress";
|
||||||
|
import { request as httpRequest } from "node:http";
|
||||||
|
import { request as httpsRequest } from "node:https";
|
||||||
|
import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
|
||||||
|
import { attach as pushAttach, attachRelay as pushAttachRelay, prepare as pushPrepare, receive as pushReceive, pushStatus } from "./push.js";
|
||||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
|
import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js";
|
||||||
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||||
import { RateLimiter } from "./ratelimit.js";
|
import { RateLimiter } from "./ratelimit.js";
|
||||||
import { resolveClientIp } from "./clientip.js";
|
import { resolveClientIp } from "./clientip.js";
|
||||||
@@ -59,6 +65,19 @@ const loginFloodLimiter = new RateLimiter(config.loginRateLimit * 20, 15 * 60_00
|
|||||||
* cannot get the whole deployment banned.
|
* cannot get the whole deployment banned.
|
||||||
*/
|
*/
|
||||||
const accountLimiter = new RateLimiter(10, 15 * 60_000);
|
const accountLimiter = new RateLimiter(10, 15 * 60_000);
|
||||||
|
const apiLimiter = new RateLimiter(config.apiRateLimit, 60_000);
|
||||||
|
|
||||||
|
/** Per-session budget on the data path. See config.apiRateLimit. */
|
||||||
|
const apiRateLimited: MiddlewareHandler<Env> = async (c, next) => {
|
||||||
|
if (config.apiRateLimit > 0) {
|
||||||
|
const session = c.get("session");
|
||||||
|
if (session && !apiLimiter.check(session.id)) {
|
||||||
|
c.header("Retry-After", String(apiLimiter.retryAfterSeconds(session.id)));
|
||||||
|
return c.json({ error: "rate_limited" }, 429);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
||||||
const HOP_BY_HOP = new Set([
|
const HOP_BY_HOP = new Set([
|
||||||
"connection",
|
"connection",
|
||||||
@@ -109,6 +128,72 @@ const securityHeaders: MiddlewareHandler = async (c, next) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** CSRF: require our custom header on all API calls; reject cross-site fetches. */
|
/** CSRF: require our custom header on all API calls; reject cross-site fetches. */
|
||||||
|
/**
|
||||||
|
* Routes that forward somebody else's bytes rather than producing our own.
|
||||||
|
*
|
||||||
|
* Compression is right for the app shell, the bundle and our JSON; it is not
|
||||||
|
* worth the risk on the proxy paths. Those carry a content-length copied from
|
||||||
|
* upstream under the rules in `forwardedContentLength`, and issue #76 was a
|
||||||
|
* silent truncation caused by exactly that header disagreeing with the body.
|
||||||
|
* Re-encoding them would be safe in principle -- the length is dropped and the
|
||||||
|
* response goes out chunked -- but the payloads are attachments, images and
|
||||||
|
* calendar data that are already compressed or too small to matter, so there
|
||||||
|
* is nothing to win and a scar to respect.
|
||||||
|
*
|
||||||
|
* `/api/events` needs no entry here: Hono skips `text/event-stream` by content
|
||||||
|
* type. It is listed anyway, because a future change to that route's type
|
||||||
|
* should not quietly start buffering the push stream.
|
||||||
|
*/
|
||||||
|
const UNCOMPRESSED_ROUTES = [
|
||||||
|
"/api/blob/",
|
||||||
|
"/api/image",
|
||||||
|
"/api/ics",
|
||||||
|
"/api/upload/",
|
||||||
|
"/api/events",
|
||||||
|
/*
|
||||||
|
* The liveness probe, which is small enough that gzip makes it bigger: 53
|
||||||
|
* bytes becomes 73. Hono's size threshold cannot catch this on its own,
|
||||||
|
* because it only applies when the response carries a content-length and
|
||||||
|
* `c.json()` does not set one. Every other JSON route is left compressed --
|
||||||
|
* a JMAP response can run to hundreds of kilobytes and its length is just as
|
||||||
|
* unknown -- so this is the one place worth naming.
|
||||||
|
*/
|
||||||
|
"/api/health",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gzip for what we generate.
|
||||||
|
*
|
||||||
|
* The bundle ships uncompressed otherwise: 915 KB on the wire where 307 KB
|
||||||
|
* would do, on every first load. `Caddyfile.example` and
|
||||||
|
* `nginx.example.conf` both compress at the proxy, but that only helps the
|
||||||
|
* deployments that use them, and the default should not depend on reading the
|
||||||
|
* examples.
|
||||||
|
*
|
||||||
|
* Hono's middleware declines anything already carrying `Content-Encoding` or
|
||||||
|
* `Transfer-Encoding`, so a proxy compressing in front of us wins and we do
|
||||||
|
* not double-encode.
|
||||||
|
*/
|
||||||
|
function compressResponses(basePath: string): MiddlewareHandler {
|
||||||
|
const inner = compress({ threshold: 1024 });
|
||||||
|
const skip = UNCOMPRESSED_ROUTES.map((r) => `${basePath}${r}`);
|
||||||
|
if (!config.compressJmap) skip.push(`${basePath}/api/jmap`);
|
||||||
|
const offersEncoding = /\b(gzip|deflate)\b/i;
|
||||||
|
return async (c, next) => {
|
||||||
|
/*
|
||||||
|
* A client that did not ask for an encoding must not pay for one. Hono's
|
||||||
|
* middleware still inspects and re-labels every compressible response it
|
||||||
|
* declines -- setting Vary forces a streamed passthrough to be rebuilt off
|
||||||
|
* its fast path -- and that was measured at 1.2 ms per JMAP call, on a
|
||||||
|
* 1.9 ms operation, for a request that never sent Accept-Encoding.
|
||||||
|
*/
|
||||||
|
if (!offersEncoding.test(c.req.header("accept-encoding") ?? "")) return next();
|
||||||
|
const path = new URL(c.req.url).pathname;
|
||||||
|
if (skip.some((prefix) => path.startsWith(prefix))) return next();
|
||||||
|
return inner(c, next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const csrfGuard: MiddlewareHandler = async (c, next) => {
|
const csrfGuard: MiddlewareHandler = async (c, 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") {
|
||||||
@@ -178,11 +263,28 @@ function upstreamFailure(c: Context, err: unknown) {
|
|||||||
export function createApp(basePath = config.basePath): Hono<Env> {
|
export function createApp(basePath = config.basePath): Hono<Env> {
|
||||||
const app = new Hono<Env>();
|
const app = new Hono<Env>();
|
||||||
app.use("*", securityHeaders);
|
app.use("*", securityHeaders);
|
||||||
|
app.use("*", compressResponses(basePath));
|
||||||
|
|
||||||
const api = new Hono<Env>();
|
const api = new Hono<Env>();
|
||||||
api.use("*", csrfGuard);
|
api.use("*", csrfGuard);
|
||||||
|
|
||||||
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version }));
|
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version, push: pushStatus() }));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Stalwart's push delivery. Authenticated by the token in the path -- 32
|
||||||
|
* random bytes, one per account, known only to us and to Stalwart -- and by
|
||||||
|
* nothing else, since Stalwart carries no credential when it POSTs. An
|
||||||
|
* unknown token is a 404 that looks like any other. See push.ts.
|
||||||
|
*/
|
||||||
|
app.post(`${basePath}/api/push/:token`, async (c) => {
|
||||||
|
if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) return c.body(null, 415);
|
||||||
|
const len = Number(c.req.header("content-length") ?? "0");
|
||||||
|
if (!len || len > 64 * 1024) return c.body(null, 413);
|
||||||
|
let body: unknown;
|
||||||
|
try { body = await c.req.json(); } catch { return c.body(null, 400); }
|
||||||
|
return c.body(null, (await pushReceive(c.req.param("token"), body)) as 200 | 400 | 404 | 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
api.get("/config", (c) =>
|
api.get("/config", (c) =>
|
||||||
c.json({
|
c.json({
|
||||||
@@ -266,6 +368,10 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
ip,
|
ip,
|
||||||
});
|
});
|
||||||
setSessionCookie(c, cookie, session.remember);
|
setSessionCookie(c, cookie, session.remember);
|
||||||
|
// Start the account's push subscription now, so it is usually verified
|
||||||
|
// by the time the browser opens its stream. See push.ts.
|
||||||
|
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||||
|
if (mailAccount) pushPrepare(session.username, mailAccount, session.authorization);
|
||||||
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) {
|
||||||
@@ -354,7 +460,11 @@ 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");
|
||||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
// The account's own server. Without it, the first fetch after the cached
|
||||||
|
// session expires goes to STALWART_URL -- which, for a domain mapped
|
||||||
|
// elsewhere, either refuses the password or knows a different account by
|
||||||
|
// the same name (#238).
|
||||||
|
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||||
return { authorization: session.authorization, session: upstream, username: session.username };
|
return { authorization: session.authorization, session: upstream, username: session.username };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -522,12 +632,36 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------- JMAP API proxy ----------
|
// ---------- JMAP API proxy ----------
|
||||||
api.post("/jmap", requireSession, async (c) => {
|
api.post("/jmap", requireSession, apiRateLimited, async (c) => {
|
||||||
const session = c.get("session");
|
const session = c.get("session");
|
||||||
const ct = c.req.header("content-type") ?? "";
|
const ct = c.req.header("content-type") ?? "";
|
||||||
if (!ct.toLowerCase().startsWith("application/json")) {
|
if (!ct.toLowerCase().startsWith("application/json")) {
|
||||||
return c.json({ error: "unsupported_media_type" }, 415);
|
return c.json({ error: "unsupported_media_type" }, 415);
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* For a session that may not administer -- administration switched off, or
|
||||||
|
* a device not marked as the person's own -- the body is read and checked
|
||||||
|
* before it goes anywhere. A session that may streams straight through as
|
||||||
|
* it always has, and pays nothing for this.
|
||||||
|
*/
|
||||||
|
let body: ReadableStream<Uint8Array> | string | null = c.req.raw.body;
|
||||||
|
if (!administrationAllowed(config.administration, session.remember)) {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
// Counted as it arrives: a chunked body carries no length to refuse up front.
|
||||||
|
raw = c.req.raw.body ? await new Response(c.req.raw.body.pipeThrough(byteCap(MAX_GATED_REQUEST))).text() : "";
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "too_large" }, 413);
|
||||||
|
}
|
||||||
|
const gate = gateAdministration(raw);
|
||||||
|
if (!gate.ok) {
|
||||||
|
if (!gate.method) return c.json({ error: "bad_request", message: "Not a JMAP request." }, 400);
|
||||||
|
return config.administration
|
||||||
|
? c.json({ error: "administration_needs_own_device", message: `Administration is only available when signed in on a device marked as your own (${gate.method}).` }, 403)
|
||||||
|
: c.json({ error: "administration_disabled", message: `Administration is turned off on this installation (${gate.method}).` }, 403);
|
||||||
|
}
|
||||||
|
body = gate.body;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||||
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||||
@@ -537,7 +671,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
},
|
},
|
||||||
body: c.req.raw.body,
|
body,
|
||||||
duplex: "half",
|
duplex: "half",
|
||||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||||
});
|
});
|
||||||
@@ -583,7 +717,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------- Blob download ----------
|
// ---------- Blob download ----------
|
||||||
api.get("/blob/:accountId/:blobId/:name", requireSession, async (c) => {
|
api.get("/blob/:accountId/:blobId/:name", requireSession, apiRateLimited, async (c) => {
|
||||||
const session = c.get("session");
|
const session = c.get("session");
|
||||||
const { accountId, blobId, name } = c.req.param();
|
const { accountId, blobId, name } = c.req.param();
|
||||||
const accept = c.req.query("accept") ?? "application/octet-stream";
|
const accept = c.req.query("accept") ?? "application/octet-stream";
|
||||||
@@ -642,6 +776,18 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
try {
|
try {
|
||||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||||
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl);
|
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl);
|
||||||
|
// Subscribe mode: if this account's subscription is verified, the tab is
|
||||||
|
// served by fan-out and holds nothing upstream. Otherwise it gets its own
|
||||||
|
// relay, and is moved to fan-out the moment the account verifies.
|
||||||
|
const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||||
|
const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing;
|
||||||
|
if (accountId && pushAttach(session.username, accountId, session.authorization, out)) {
|
||||||
|
out.writeHead(200, SSE_HEADERS);
|
||||||
|
out.flushHeaders();
|
||||||
|
out.write(": subscribed\n\n");
|
||||||
|
return RESPONSE_ALREADY_SENT;
|
||||||
|
}
|
||||||
|
if (config.rawPushRelay) return relayPushRaw(c, url, session.authorization, session.username);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
c.req.raw.signal.addEventListener("abort", () => controller.abort());
|
c.req.raw.signal.addEventListener("abort", () => controller.abort());
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -662,10 +808,10 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------- Remote image privacy proxy ----------
|
// ---------- Remote image privacy proxy ----------
|
||||||
api.get("/image", requireSession, imageProxyHandler);
|
api.get("/image", requireSession, apiRateLimited, imageProxyHandler);
|
||||||
// Behind the session for the same reason the image proxy is: an open fetcher
|
// Behind the session for the same reason the image proxy is: an open fetcher
|
||||||
// on someone else's server is a gift to whoever finds it.
|
// on someone else's server is a gift to whoever finds it.
|
||||||
api.get("/ics", requireSession, icsProxyHandler);
|
api.get("/ics", requireSession, apiRateLimited, icsProxyHandler);
|
||||||
|
|
||||||
api.notFound((c) => c.json({ error: "not_found" }, 404));
|
api.notFound((c) => c.json({ error: "not_found" }, 404));
|
||||||
api.onError((err, c) => {
|
api.onError((err, c) => {
|
||||||
@@ -707,7 +853,7 @@ function appPasswordName(c: Context): string {
|
|||||||
return `${config.appName} (${browser})`;
|
return `${config.appName} (${browser})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null }) {
|
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null, permissions: [] }) {
|
||||||
return {
|
return {
|
||||||
ihasmail: {
|
ihasmail: {
|
||||||
appName: config.appName,
|
appName: config.appName,
|
||||||
@@ -721,6 +867,24 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
|||||||
userLocale: info.locale,
|
userLocale: info.locale,
|
||||||
/** What the upstream server would tell us about itself. */
|
/** What the upstream server would tell us about itself. */
|
||||||
server: { edition: info.edition },
|
server: { edition: info.edition },
|
||||||
|
/**
|
||||||
|
* Whether this session may administer: the installation offers it
|
||||||
|
* (ADMINISTRATION) and the person signed in on a device marked as their own.
|
||||||
|
*/
|
||||||
|
administration: administrationAllowed(config.administration, session.remember),
|
||||||
|
/**
|
||||||
|
* An administrator signed in on a device not marked as their own, so the
|
||||||
|
* menu can say why Administration is unavailable rather than lose it
|
||||||
|
* without a word. Says only that the account administers, never what it
|
||||||
|
* may do.
|
||||||
|
*/
|
||||||
|
administrationNeedsOwnDevice: config.administration && !session.remember && grantsAdministration(info.permissions),
|
||||||
|
/**
|
||||||
|
* The account's permissions on that server, so the client can offer
|
||||||
|
* administration to those who have it. Stalwart still decides every call.
|
||||||
|
* Withheld from a session that may not administer: nothing in it needs them.
|
||||||
|
*/
|
||||||
|
permissions: administrationAllowed(config.administration, session.remember) ? info.permissions : [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -730,8 +894,91 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
|||||||
* denylist: everything else it might set — cookies, auth challenges, CORS
|
* denylist: everything else it might set — cookies, auth challenges, CORS
|
||||||
* grants — would be landing on *our* origin, where it means something else.
|
* grants — would be landing on *our* origin, where it means something else.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The largest JMAP request read into memory for the administration check.
|
||||||
|
* Stalwart's own default `maxSizeRequest` is 10 MB; uploads never come this way.
|
||||||
|
*/
|
||||||
|
const MAX_GATED_REQUEST = 16 * 1024 * 1024;
|
||||||
|
|
||||||
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hold a push stream open with the least machinery that will do it.
|
||||||
|
*
|
||||||
|
* The fetch() version above builds an undici Response, a web ReadableStream,
|
||||||
|
* a reader, and Hono's stream-to-Node bridge for every tab, and keeps all of
|
||||||
|
* it alive for as long as the tab is open. Measured against a real Stalwart
|
||||||
|
* that is about 44 KiB of JavaScript heap per tab -- twelve times what the
|
||||||
|
* session itself costs -- and a signed-in tab is otherwise nothing but this
|
||||||
|
* one held connection. Here the upstream socket is piped straight into the
|
||||||
|
* Node response, so what stays resident per tab is two sockets and their
|
||||||
|
* small IncomingMessage/ServerResponse pair.
|
||||||
|
*
|
||||||
|
* Returns a Response Hono treats as already sent: the raw bindings are
|
||||||
|
* written to directly, and the returned value is never serialised.
|
||||||
|
*/
|
||||||
|
const SSE_HEADERS = {
|
||||||
|
"content-type": "text/event-stream",
|
||||||
|
"cache-control": "no-cache, no-transform",
|
||||||
|
connection: "keep-alive",
|
||||||
|
"x-accel-buffering": "no",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function relayPushRaw(c: Context<Env>, url: string, authorization: string, username?: string): Response {
|
||||||
|
const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing;
|
||||||
|
const target = new URL(url);
|
||||||
|
const req = (target.protocol === "https:" ? httpsRequest : httpRequest)(target, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { authorization, accept: "text/event-stream" },
|
||||||
|
});
|
||||||
|
const signal = c.req.raw.signal;
|
||||||
|
const abort = () => req.destroy();
|
||||||
|
signal.addEventListener("abort", abort);
|
||||||
|
out.on("close", abort);
|
||||||
|
const fail = () => {
|
||||||
|
if (!out.headersSent) {
|
||||||
|
out.writeHead(502, { "content-type": "application/json", "cache-control": "no-store" });
|
||||||
|
out.end(JSON.stringify({ error: "upstream_error" }));
|
||||||
|
} else {
|
||||||
|
out.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/*
|
||||||
|
* Once this account's subscription verifies, the upstream request goes and
|
||||||
|
* the browser stream below is served by fan-out instead. Three things have
|
||||||
|
* to be true for that to be seamless: the browser must already have its
|
||||||
|
* headers (verification can beat the upstream response); nothing may treat
|
||||||
|
* the torn-down upstream as an error; and nothing may keep a reference to
|
||||||
|
* it -- the request, its response and this handler's context are exactly
|
||||||
|
* the per-tab weight the subscription exists to shed.
|
||||||
|
*/
|
||||||
|
let migrated = false;
|
||||||
|
const migrate = () => {
|
||||||
|
migrated = true;
|
||||||
|
if (!out.headersSent) { out.writeHead(200, SSE_HEADERS); out.flushHeaders(); }
|
||||||
|
signal.removeEventListener("abort", abort);
|
||||||
|
out.removeListener("close", abort);
|
||||||
|
req.removeAllListeners();
|
||||||
|
req.on("error", () => {});
|
||||||
|
req.destroy();
|
||||||
|
};
|
||||||
|
if (username) pushAttachRelay(username, out, migrate);
|
||||||
|
req.on("response", (res) => {
|
||||||
|
if (migrated) { res.destroy(); return; }
|
||||||
|
if (res.statusCode !== 200) { res.resume(); fail(); return; }
|
||||||
|
if (!out.headersSent) { out.writeHead(200, SSE_HEADERS); out.flushHeaders(); }
|
||||||
|
// end: false -- the browser stream outlives the upstream if we migrate.
|
||||||
|
res.pipe(out, { end: false });
|
||||||
|
res.on("end", () => { if (!migrated) out.end(); });
|
||||||
|
res.on("error", () => { if (!migrated) out.end(); });
|
||||||
|
});
|
||||||
|
req.on("error", () => { if (!migrated) fail(); });
|
||||||
|
req.end();
|
||||||
|
// Tells @hono/node-server the raw ServerResponse has been written to and
|
||||||
|
// must be left alone.
|
||||||
|
return RESPONSE_ALREADY_SENT;
|
||||||
|
}
|
||||||
|
|
||||||
function passthrough(res: Response): Response {
|
function passthrough(res: Response): Response {
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
res.headers.forEach((v, k) => {
|
res.headers.forEach((v, k) => {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A static root of our own, built before the app is imported.
|
||||||
|
*
|
||||||
|
* CI runs `npm test` before `npm run build`, so `web/dist` does not exist when
|
||||||
|
* these run: pointing at it would serve the "web build not found" fallback,
|
||||||
|
* which is short, plain text and rightly uncompressed. That failure looked
|
||||||
|
* exactly like compression being broken.
|
||||||
|
*/
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "ihasmail-compress-"));
|
||||||
|
mkdirSync(join(root, "assets"));
|
||||||
|
const script = `/* ${"x".repeat(40_000)} */\n`;
|
||||||
|
writeFileSync(join(root, "assets", "app.js"), script);
|
||||||
|
writeFileSync(join(root, "index.html"), `<!doctype html><title>t</title>${"<p>hello</p>".repeat(400)}`);
|
||||||
|
|
||||||
|
process.env.STATIC_DIR = root;
|
||||||
|
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||||
|
const { createApp } = await import("./app.js");
|
||||||
|
|
||||||
|
test("an asset is gzipped when the client asks for it", async () => {
|
||||||
|
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers.get("content-encoding"), "gzip");
|
||||||
|
assert.match(res.headers.get("vary") ?? "", /accept-encoding/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a client that does not ask for gzip does not get it", async () => {
|
||||||
|
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers.get("content-encoding"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gzip actually makes the asset smaller", async () => {
|
||||||
|
const plain = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } })).arrayBuffer();
|
||||||
|
const gz = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } })).arrayBuffer();
|
||||||
|
assert.ok(gz.byteLength < plain.byteLength / 2, `${gz.byteLength} should be well under ${plain.byteLength}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a gzipped response decodes to the bytes we would have sent plain", async () => {
|
||||||
|
const plain = await (await createApp().request("/assets/app.js", { headers: { "accept-encoding": "identity" } })).arrayBuffer();
|
||||||
|
const res = await createApp().request("/assets/app.js", { headers: { "accept-encoding": "gzip" } });
|
||||||
|
const decoded = await new Response(res.body!.pipeThrough(new DecompressionStream("gzip"))).arrayBuffer();
|
||||||
|
assert.deepEqual(Buffer.from(decoded), Buffer.from(plain));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the app shell is gzipped", async () => {
|
||||||
|
const res = await createApp().request("/", { headers: { "accept-encoding": "gzip" } });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers.get("content-encoding"), "gzip");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("proxy routes that forward upstream bytes are never compressed", async () => {
|
||||||
|
// Unauthenticated, so these stop at 401 -- enough to prove the middleware
|
||||||
|
// declines the path, which is what issue #76 was about.
|
||||||
|
const app = createApp();
|
||||||
|
for (const path of ["/api/blob/a/b/c.pdf", "/api/image?url=https://example.com/x.png", "/api/ics?url=https://example.com/x.ics"]) {
|
||||||
|
const res = await app.request(path, { headers: { "accept-encoding": "gzip" } });
|
||||||
|
assert.equal(res.headers.get("content-encoding"), null, `${path} must not be compressed`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the push stream is never compressed", async () => {
|
||||||
|
const res = await createApp().request("/api/events", { headers: { "accept-encoding": "gzip" } });
|
||||||
|
assert.equal(res.headers.get("content-encoding"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the liveness probe is not compressed, since gzip would make it bigger", async () => {
|
||||||
|
const res = await createApp().request("/api/health", { headers: { "accept-encoding": "gzip" } });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers.get("content-encoding"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("advertised upstream URLs are pinned to the configured origin", async () => {
|
||||||
|
const { absoluteUpstream } = await import("./upstream.js");
|
||||||
|
const pinned = absoluteUpstream("https://mail.public.example/jmap/eventsource/?types=*", "http://stalwart:8080");
|
||||||
|
assert.equal(pinned, "http://stalwart:8080/jmap/eventsource/?types=*");
|
||||||
|
// A relative URL still resolves against the base, as before.
|
||||||
|
assert.equal(absoluteUpstream("/jmap/", "http://stalwart:8080/"), "http://stalwart:8080/jmap/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the data path is rate limited per session, and login stays on its own budget", async () => {
|
||||||
|
// No session: every call is refused before the limiter, so it must never 429.
|
||||||
|
const app = createApp();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const res = await app.request("/api/jmap", { method: "POST",
|
||||||
|
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" }, body: "{}" });
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
}
|
||||||
|
// The limiter itself: a fresh key gets its budget and nothing more.
|
||||||
|
const { RateLimiter } = await import("./ratelimit.js");
|
||||||
|
const l = new RateLimiter(3, 60_000);
|
||||||
|
assert.deepEqual([l.check("s1"), l.check("s1"), l.check("s1"), l.check("s1")], [true, true, true, false]);
|
||||||
|
assert.ok(l.retryAfterSeconds("s1") >= 1);
|
||||||
|
assert.equal(l.check("s2"), true, "another session is not affected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a response to a client that offered no encoding is not touched by the compressor", async () => {
|
||||||
|
const res = await createApp().request("/assets/app.js"); // no Accept-Encoding at all
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers.get("content-encoding"), null);
|
||||||
|
assert.equal(res.headers.get("vary"), null, "no Vary: the middleware never ran");
|
||||||
|
});
|
||||||
@@ -295,9 +295,41 @@ export const config = {
|
|||||||
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
|
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
|
||||||
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
|
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
|
||||||
imageProxy: bool("IMAGE_PROXY", true),
|
imageProxy: bool("IMAGE_PROXY", true),
|
||||||
|
/*
|
||||||
|
* Whether ihasmail offers administration to accounts whose Stalwart role
|
||||||
|
* allows it. Off means off: no menu, no permissions sent to the browser, and
|
||||||
|
* the JMAP proxy refuses registry methods beyond the account's own -- see
|
||||||
|
* adminGate.ts. Stalwart's own interface is unaffected either way.
|
||||||
|
*/
|
||||||
|
administration: bool("ADMINISTRATION", true),
|
||||||
cookieName: env("COOKIE_NAME", "ihm_session"),
|
cookieName: env("COOKIE_NAME", "ihm_session"),
|
||||||
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
||||||
loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
|
loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
|
||||||
|
/*
|
||||||
|
* Requests per minute one session may make on the data path -- JMAP, blobs,
|
||||||
|
* the image and calendar proxies. The proxy is one Node process and saturates
|
||||||
|
* a core at roughly 2,000 operations a second, so without this a single
|
||||||
|
* signed-in user can deny service to everyone else. 1,200 a minute is twenty
|
||||||
|
* a second sustained: well above what a busy tab does, and an order of
|
||||||
|
* magnitude below where one tab starts to hurt the rest. 0 disables it.
|
||||||
|
*/
|
||||||
|
apiRateLimit: int("API_RATE_LIMIT", 1200),
|
||||||
|
/* Whether JMAP responses are gzipped. Measured: see the bake-off rerun. */
|
||||||
|
compressJmap: process.env.COMPRESS_JMAP !== "0",
|
||||||
|
/*
|
||||||
|
* How push reaches the browser. "relay" holds one upstream stream per tab
|
||||||
|
* (today's behaviour). "subscribe" registers one JMAP PushSubscription per
|
||||||
|
* account and fans Stalwart's POSTs out to that account's tabs, holding no
|
||||||
|
* upstream connection at all -- see push.ts. It needs PUSH_URL: the https
|
||||||
|
* origin Stalwart can reach ihasmail at, with a certificate it trusts.
|
||||||
|
* An account that cannot be verified stays on the relay.
|
||||||
|
*/
|
||||||
|
pushMode: (process.env.PUSH_MODE === "relay" ? "relay" : "subscribe") as "relay" | "subscribe",
|
||||||
|
pushUrl: process.env.PUSH_URL || "",
|
||||||
|
/* See relayPushRaw(): pipe the push stream socket-to-socket instead of through fetch(). */
|
||||||
|
rawPushRelay: process.env.RAW_PUSH_RELAY !== "0",
|
||||||
|
/* See absoluteUpstream(): follow Stalwart's advertised origin instead of pinning to ours. */
|
||||||
|
followAdvertisedUrls: process.env.STALWART_FOLLOW_ADVERTISED_URLS === "1",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Config = typeof config;
|
export type Config = typeof config;
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createDirectory, permissionsFor, type MockRole } from "./directory.js";
|
||||||
|
|
||||||
|
class Refused extends Error {
|
||||||
|
constructor(readonly type: string, description?: string) { super(description ?? type); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const make = (role: MockRole) => createDirectory({ accountId: "a1", user: "[email protected]", locale: "en_US", role, fail: (t, d) => new Refused(t, d) });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mock stands in for a server that decides what each account may do, so
|
||||||
|
* the client's administration can be developed against refusals as well as
|
||||||
|
* successes. These pin the refusals.
|
||||||
|
*/
|
||||||
|
test("an ordinary user is refused the directory outright", () => {
|
||||||
|
const dir = make("user");
|
||||||
|
assert.throws(() => dir.handlers["x:Account/query"]!({}), (e: Refused) => e.type === "forbidden");
|
||||||
|
assert.ok(!permissionsFor("user").some((p) => p.startsWith("sysAccountQuery")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("helpdesk may read and edit but not create or delete", () => {
|
||||||
|
const dir = make("helpdesk");
|
||||||
|
const { ids } = dir.handlers["x:Account/query"]!({ filter: { "@type": "User" } }) as { ids: string[] };
|
||||||
|
assert.ok(ids.length > 20);
|
||||||
|
assert.throws(() => dir.handlers["x:Account/set"]!({ create: { n: { name: "x", domainId: "d1" } } }), (e: Refused) => e.type === "forbidden");
|
||||||
|
assert.throws(() => dir.handlers["x:Account/set"]!({ destroy: [ids[0]] }), (e: Refused) => e.type === "forbidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("queries page, count and match text the way the client asks", () => {
|
||||||
|
const dir = make("admin");
|
||||||
|
const all = dir.handlers["x:Account/query"]!({ filter: { "@type": "User" }, calculateTotal: true }) as { ids: string[]; total: number };
|
||||||
|
const page = dir.handlers["x:Account/query"]!({ filter: { "@type": "User" }, position: 10, limit: 5, calculateTotal: true }) as { ids: string[]; total: number };
|
||||||
|
assert.equal(page.total, all.total);
|
||||||
|
assert.deepEqual(page.ids, all.ids.slice(10, 15));
|
||||||
|
const ada = dir.handlers["x:Account/query"]!({ filter: { "@type": "User", text: "lovelace" } }) as { ids: string[] };
|
||||||
|
assert.equal(ada.ids.length, 1);
|
||||||
|
assert.throws(() => dir.handlers["x:Account/query"]!({ filter: { operator: "OR", conditions: [] } }), (e: Refused) => e.type === "unsupportedFilter");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an address already used as an alias cannot be taken", () => {
|
||||||
|
const dir = make("admin");
|
||||||
|
const res = dir.handlers["x:Account/set"]!({ create: { n: { "@type": "User", name: "postmaster", domainId: "d1", credentials: { "0": { "@type": "Password", secret: "long enough secret" } }, roles: { "@type": "User" } } } }) as { notCreated?: Record<string, { type: string }> };
|
||||||
|
assert.equal(res.notCreated?.n?.type, "primaryKeyViolation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a password is set through its credential's pointer, and a weak one is refused", () => {
|
||||||
|
const dir = make("admin");
|
||||||
|
const set = dir.handlers["x:Account/set"]!;
|
||||||
|
assert.equal((set({ update: { a1: { "credentials/0/secret": "short" } } }) as { notUpdated?: Record<string, { properties: string[] }> }).notUpdated?.a1?.properties[0], "secret");
|
||||||
|
assert.deepEqual((set({ update: { a1: { "credentials/0/secret": "a much longer secret" } } }) as { updated: object }).updated, { a1: null });
|
||||||
|
const got = dir.handlers["x:Account/get"]!({ ids: ["a1"], properties: ["credentials"] }) as { list: Array<{ credentials: Record<string, { secret: string }> }> };
|
||||||
|
assert.equal(got.list[0]!.credentials["0"]!.secret, "[********]", "never echoed back");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a grant the caller does not hold is refused", () => {
|
||||||
|
const dir = make("helpdesk");
|
||||||
|
const res = dir.handlers["x:Account/set"]!({ update: { u101: { roles: { "@type": "Admin" } } } }) as { notUpdated?: Record<string, { type: string }> };
|
||||||
|
assert.equal(res.notUpdated?.u101?.type, "forbidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an administrator can delete an account, and a group with members is kept", () => {
|
||||||
|
const dir = make("admin");
|
||||||
|
const set = dir.handlers["x:Account/set"]!;
|
||||||
|
assert.deepEqual((set({ destroy: ["u101"] }) as { destroyed: string[] }).destroyed, ["u101"]);
|
||||||
|
assert.equal((set({ destroy: ["g1"] }) as { notDestroyed?: Record<string, { type: string }> }).notDestroyed?.g1?.type, "objectIsLinked");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a domain in use is kept, and names what uses it", () => {
|
||||||
|
const dir = make("admin");
|
||||||
|
const set = dir.handlers["x:Domain/set"]!;
|
||||||
|
const res = set({ destroy: ["d1"] }) as { notDestroyed?: Record<string, { type: string; linkedObjects: Array<{ object: string }> }> };
|
||||||
|
assert.equal(res.notDestroyed?.d1?.type, "objectIsLinked");
|
||||||
|
const kinds = new Set(res.notDestroyed?.d1?.linkedObjects.map((o) => o.object));
|
||||||
|
assert.deepEqual([...kinds].sort(), ["Account", "DkimSignature"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unused domain goes once its keys do", () => {
|
||||||
|
const dir = make("admin");
|
||||||
|
const created = dir.handlers["x:Domain/set"]!({ create: { n: { name: "fresh.example.net" } } }) as { created: Record<string, { id: string }> };
|
||||||
|
const id = created.created.n!.id;
|
||||||
|
const keys = dir.handlers["x:DkimSignature/query"]!({ filter: { domainId: id } }) as { ids: string[] };
|
||||||
|
assert.equal(keys.ids.length, 1, "automatic DKIM makes a key straight away");
|
||||||
|
assert.equal((dir.handlers["x:Domain/set"]!({ destroy: [id] }) as { notDestroyed?: object }).notDestroyed !== undefined, true);
|
||||||
|
dir.handlers["x:DkimSignature/set"]!({ destroy: keys.ids });
|
||||||
|
assert.deepEqual((dir.handlers["x:Domain/set"]!({ destroy: [id] }) as { destroyed: string[] }).destroyed, [id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a domain's zone file is computed on read, with long keys split as the server splits them", () => {
|
||||||
|
const dir = make("admin");
|
||||||
|
const got = dir.handlers["x:Domain/get"]!({ ids: ["d1"], properties: ["name", "dnsZoneFile"] }) as { list: Array<{ dnsZoneFile: string }> };
|
||||||
|
const zone = got.list[0]!.dnsZoneFile;
|
||||||
|
assert.match(zone, /IN MX 10 /);
|
||||||
|
assert.match(zone, /_domainkey\.example\.com\. IN TXT \(\n {4}"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a filter on a name the registry does not index is refused, as the live server refuses it", () => {
|
||||||
|
const dir = make("admin");
|
||||||
|
// Seen on a live 0.16 server: "x:Account/query: unsupportedFilter - type".
|
||||||
|
assert.throws(() => dir.handlers["x:Account/query"]!({ filter: { type: "User" } }), (e: Refused) => e.type === "unsupportedFilter" && e.message === "type");
|
||||||
|
assert.doesNotThrow(() => dir.handlers["x:Account/query"]!({ filter: { "@type": "Group", domainId: "d1", text: "x" } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the domain validators refuse what the live server refused, in its words", () => {
|
||||||
|
const dir = make("admin");
|
||||||
|
const set = dir.handlers["x:Domain/set"]!;
|
||||||
|
const created = set({ create: { n: { name: "admin-test.example" } } }) as { notCreated?: Record<string, { type: string; description: string }> };
|
||||||
|
assert.deepEqual([created.notCreated?.n?.type, created.notCreated?.n?.description], ["invalidPatch", "Invalid domain name"]);
|
||||||
|
const updated = set({ update: { d2: { catchAllAddress: "postmaster" } } }) as { notUpdated?: Record<string, { type: string; description: string }> };
|
||||||
|
assert.deepEqual([updated.notUpdated?.d2?.type, updated.notUpdated?.d2?.description], ["invalidPatch", "Invalid email address"]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
/**
|
||||||
|
* Enough of Stalwart 0.16's directory registry to develop administration
|
||||||
|
* against: `x:Account`, `x:Domain` and `x:Role`, gated by permission names the
|
||||||
|
* way the real server gates them.
|
||||||
|
*
|
||||||
|
* Shapes follow the 0.16.22 source rather than the documentation, which has
|
||||||
|
* been wrong about both before:
|
||||||
|
*
|
||||||
|
* - a `List<T>` (credentials, aliases) is an object keyed by index -- `{"0": …}`
|
||||||
|
* -- and a `Set` (memberGroupIds, enabledPermissions) is `{"id": true}`;
|
||||||
|
* - an account's `name` is the local part only, and it lives on a domain by id;
|
||||||
|
* - secrets come back masked, and a new one is written through the password
|
||||||
|
* credential's own pointer, `credentials/<index>/secret`;
|
||||||
|
* - `x:Account/query` understands AND and nothing else.
|
||||||
|
*
|
||||||
|
* What it does not reproduce is tenancy: every caller sees every record. The
|
||||||
|
* real server scopes a tenant administrator's queries, and nothing in the client
|
||||||
|
* relies on seeing more or less than it is given.
|
||||||
|
*
|
||||||
|
* MOCK_ROLE picks who the demo user is: `admin` (the default), `tenant-admin`,
|
||||||
|
* `helpdesk` (a custom role that may view and edit accounts but not create or
|
||||||
|
* delete them) or `user`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Obj = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type MockRole = "admin" | "tenant-admin" | "helpdesk" | "user";
|
||||||
|
|
||||||
|
const OPS = ["Get", "Query", "Create", "Update", "Destroy"] as const;
|
||||||
|
const all = (...objects: string[]) => objects.flatMap((o) => OPS.map((op) => `sys${o}${op}`));
|
||||||
|
|
||||||
|
/** A few of the ordinary ones, so the list looks like what a server sends. */
|
||||||
|
const USER_PERMISSIONS = ["jmapEmailGet", "jmapEmailSet", "jmapMailboxGet", "sysAccountSettingsGet"];
|
||||||
|
|
||||||
|
export function permissionsFor(role: MockRole): string[] {
|
||||||
|
switch (role) {
|
||||||
|
case "admin":
|
||||||
|
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer", "Tenant"), "impersonate"];
|
||||||
|
case "tenant-admin":
|
||||||
|
return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer")];
|
||||||
|
case "helpdesk":
|
||||||
|
return [...USER_PERMISSIONS, "sysAccountGet", "sysAccountQuery", "sysAccountUpdate"];
|
||||||
|
default:
|
||||||
|
return USER_PERMISSIONS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockRole(raw: string | undefined): MockRole {
|
||||||
|
return raw === "tenant-admin" || raw === "helpdesk" || raw === "user" ? raw : "admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASKED = "[********]";
|
||||||
|
const GIB = 1024 ** 3;
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
/** The demo user's JMAP account id, which is also its registry id. */
|
||||||
|
accountId: string;
|
||||||
|
/** The demo user's address. */
|
||||||
|
user: string;
|
||||||
|
locale: string;
|
||||||
|
role: MockRole;
|
||||||
|
/** Build the error a method fails with; the mock server owns the type. */
|
||||||
|
fail: (type: string, description?: string) => Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDirectory(opts: Options) {
|
||||||
|
const permissions = new Set(permissionsFor(opts.role));
|
||||||
|
const [userLocal, userDomain] = splitAddress(opts.user);
|
||||||
|
let counter = 100;
|
||||||
|
|
||||||
|
const managed = (dns: boolean, dkim: boolean, certs: boolean) => ({
|
||||||
|
dnsManagement: dns ? { "@type": "Automatic", dnsServerId: "ns1", origin: null, publishRecords: {} } : { "@type": "Manual" },
|
||||||
|
dkimManagement: dkim ? { "@type": "Automatic", algorithms: { Dkim1Ed25519Sha256: true, Dkim1RsaSha256: true }, selectorTemplate: "v{version}-{algorithm}-{date-%Y%m%d}" } : { "@type": "Manual" },
|
||||||
|
certificateManagement: certs ? { "@type": "Automatic", acmeProviderId: "acme1", subjectAlternativeNames: {} } : { "@type": "Manual" },
|
||||||
|
});
|
||||||
|
const domain = (id: string, name: string, extra: Obj = {}): Obj => ({
|
||||||
|
id, name, aliases: {}, isEnabled: true, createdAt: "2026-06-01T09:00:00Z", description: null, logo: null,
|
||||||
|
...managed(false, true, false), memberTenantId: null, directoryId: null, catchAllAddress: null,
|
||||||
|
subAddressing: { "@type": "Enabled" }, allowRelaying: false, reportAddressUri: "mailto:postmaster", allowScimProvisioning: false, ...extra,
|
||||||
|
});
|
||||||
|
const domains: Obj[] = [
|
||||||
|
domain("d1", userDomain, { ...managed(true, true, true), aliases: { [`mail.${userDomain}`]: true }, description: "Main domain" }),
|
||||||
|
domain("d2", userDomain === "example.org" ? "example.net" : "example.org", { catchAllAddress: `postmaster@${userDomain}` }),
|
||||||
|
domain("d3", "old-brand.example", { ...managed(false, false, false), description: "No longer used", subAddressing: { "@type": "Custom", customRule: "..." } }),
|
||||||
|
];
|
||||||
|
const dkimKeys: Obj[] = [
|
||||||
|
{ id: "k1", "@type": "Dkim1Ed25519Sha256", domainId: "d1", selector: "v1-ed25519-20260601", stage: "active", createdAt: "2026-06-01T09:00:00Z", nextTransitionAt: "2026-08-30T09:00:00Z", memberTenantId: null },
|
||||||
|
{ id: "k2", "@type": "Dkim1RsaSha256", domainId: "d1", selector: "v1-rsa-20260601", stage: "active", createdAt: "2026-06-01T09:00:00Z", nextTransitionAt: "2026-08-30T09:00:00Z", memberTenantId: null },
|
||||||
|
{ id: "k3", "@type": "Dkim1Ed25519Sha256", domainId: "d2", selector: "v1-ed25519-20260710", stage: "active", createdAt: "2026-07-10T09:00:00Z", nextTransitionAt: null, memberTenantId: null },
|
||||||
|
];
|
||||||
|
/** What Stalwart's BIND serialiser writes, including a TXT long enough to be split. */
|
||||||
|
const zoneFile = (d: Obj): string => {
|
||||||
|
const n = String(d.name);
|
||||||
|
const lines = [
|
||||||
|
`${n}. IN MX 10 mail.${userDomain}.`,
|
||||||
|
`${n}. IN TXT "v=spf1 mx ra=postmaster -all"`,
|
||||||
|
];
|
||||||
|
for (const k of dkimKeys.filter((k) => k.domainId === d.id && k.stage !== "retired")) {
|
||||||
|
if (String(k["@type"]).includes("Rsa")) {
|
||||||
|
const p = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA" + "x".repeat(300) + "IDAQAB";
|
||||||
|
const txt = `v=DKIM1; k=rsa; h=sha256; p=${p}`;
|
||||||
|
lines.push(`${k.selector}._domainkey.${n}. IN TXT (`, ...(txt.match(/.{1,255}/g) ?? []).map((c) => ` "${c}"`), ")");
|
||||||
|
} else {
|
||||||
|
lines.push(`${k.selector}._domainkey.${n}. IN TXT "v=DKIM1; k=ed25519; h=sha256; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
`_dmarc.${n}. IN TXT "v=DMARC1; p=reject; rua=mailto:postmaster@${n}; ruf=mailto:postmaster@${n}"`,
|
||||||
|
`_jmap._tcp.${n}. IN SRV 0 1 443 mail.${userDomain}.`,
|
||||||
|
`_submissions._tcp.${n}. IN SRV 0 1 465 mail.${userDomain}.`,
|
||||||
|
`_imaps._tcp.${n}. IN SRV 0 1 993 mail.${userDomain}.`,
|
||||||
|
`mta-sts.${n}. IN CNAME mail.${userDomain}.`,
|
||||||
|
`_mta-sts.${n}. IN TXT "v=STSv1; id=16837364213434767412"`,
|
||||||
|
`_smtp._tls.${n}. IN TXT "v=TLSRPTv1; rua=mailto:postmaster@${n}"`,
|
||||||
|
`autoconfig.${n}. IN CNAME mail.${userDomain}.`,
|
||||||
|
`${n}. IN CAA 0 issue "letsencrypt.org"`,
|
||||||
|
);
|
||||||
|
return lines.join("\n") + "\n";
|
||||||
|
};
|
||||||
|
|
||||||
|
const roles: Obj[] = [
|
||||||
|
{ id: "r1", description: "User", enabledPermissions: flags(USER_PERMISSIONS), disabledPermissions: {}, roleIds: {} },
|
||||||
|
{ id: "r2", description: "Helpdesk", enabledPermissions: flags(permissionsFor("helpdesk").filter((p) => p.startsWith("sys"))), disabledPermissions: {}, roleIds: { r1: true } },
|
||||||
|
{ id: "r3", description: "Directory manager", enabledPermissions: flags(all("Account")), disabledPermissions: {}, roleIds: { r1: true } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ownRoles = opts.role === "admin" || opts.role === "tenant-admin" ? { "@type": "Admin" } : opts.role === "helpdesk" ? { "@type": "Custom", roleIds: { r2: true } } : { "@type": "User" };
|
||||||
|
|
||||||
|
const accounts: Obj[] = [];
|
||||||
|
const user = (o: { id?: string; name: string; domain?: string; description: string; roles?: Obj; used?: number; quota?: number; aliases?: string[]; groups?: string[]; password?: boolean }) => {
|
||||||
|
const domainId = o.domain === "d2" ? "d2" : "d1";
|
||||||
|
const row: Obj = {
|
||||||
|
id: o.id ?? `u${counter++}`,
|
||||||
|
"@type": "User",
|
||||||
|
name: o.name,
|
||||||
|
domainId,
|
||||||
|
description: o.description,
|
||||||
|
credentials: o.password === false ? {} : { "0": { "@type": "Password", credentialId: "0", secret: MASKED, otpAuth: null, expiresAt: null, allowedIps: {} } },
|
||||||
|
createdAt: new Date(Date.now() - counter * 86_400_000).toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||||
|
memberGroupIds: flags(o.groups ?? []),
|
||||||
|
memberTenantId: null,
|
||||||
|
roles: o.roles ?? { "@type": "User" },
|
||||||
|
permissions: { "@type": "Inherit" },
|
||||||
|
quotas: o.quota ? { maxDiskQuota: o.quota * GIB } : {},
|
||||||
|
usedDiskQuota: Math.round((o.used ?? 0) * GIB),
|
||||||
|
aliases: Object.fromEntries((o.aliases ?? []).map((name, i) => [String(i), { enabled: true, name, domainId, description: null }])),
|
||||||
|
locale: opts.locale,
|
||||||
|
timeZone: null,
|
||||||
|
};
|
||||||
|
accounts.push(row);
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
const group = (id: string, name: string, description: string) =>
|
||||||
|
accounts.push({ id, "@type": "Group", name, domainId: "d1", description, memberTenantId: null, roles: { "@type": "User" }, permissions: { "@type": "Inherit" }, quotas: {}, usedDiskQuota: 0, aliases: {} });
|
||||||
|
|
||||||
|
group("g1", "support", "Support");
|
||||||
|
group("g2", "office", "Office");
|
||||||
|
user({ id: opts.accountId, name: userLocal, description: "Demo User", roles: ownRoles, used: 1.4, quota: 10, aliases: ["postmaster"], groups: ["g1"] });
|
||||||
|
user({ name: "ada", domain: "d2", description: "Ada Lovelace", used: 3.2, quota: 5, groups: ["g2"] });
|
||||||
|
user({ name: "grace", domain: "d2", description: "Grace Hopper", used: 4.7, quota: 5, groups: ["g2"] });
|
||||||
|
user({ name: "alan", domain: "d2", description: "Alan Turing", roles: { "@type": "Custom", roleIds: { r2: true } }, used: 0.8, quota: 5, groups: ["g1"] });
|
||||||
|
user({ name: "margaret", description: "Margaret Hamilton", roles: { "@type": "Admin" }, used: 2.1, quota: 20 });
|
||||||
|
user({ name: "katherine", description: "Katherine Johnson", roles: { "@type": "Custom", roleIds: { r3: true } }, used: 0.4, quota: 5 });
|
||||||
|
user({ name: "sso.only", description: "Signs in with SSO", password: false, used: 0.1 });
|
||||||
|
const people = ["Edsger Dijkstra", "Barbara Liskov", "Donald Knuth", "Frances Allen", "John Backus", "Radia Perlman", "Ken Thompson", "Hedy Lamarr", "Dennis Ritchie", "Karen Spärck Jones", "Tim Berners-Lee", "Sophie Wilson", "Niklaus Wirth", "Jean Sammet", "Leslie Lamport", "Mary Kenneth Keller", "Tony Hoare", "Evelyn Berezin", "Butler Lampson", "Shafi Goldwasser", "Whitfield Diffie", "Adele Goldberg", "Vint Cerf", "Anita Borg", "Bob Kahn", "Lynn Conway", "Charles Babbage", "Annie Easley"];
|
||||||
|
people.forEach((description, i) => {
|
||||||
|
const name = description.toLowerCase().split(" ")[0]!.normalize("NFD").replace(/[^a-z]/g, "");
|
||||||
|
user({ name, domain: i % 3 === 0 ? "d2" : "d1", description, used: (i % 7) * 0.6, quota: i % 4 === 0 ? 0 : 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const demand = (perm: string) => {
|
||||||
|
if (!permissions.has(perm)) throw opts.fail("forbidden", `You do not have the ${perm} permission.`);
|
||||||
|
};
|
||||||
|
const domainName = (id: unknown) => domains.find((d) => d.id === id)?.name as string | undefined;
|
||||||
|
const addressOf = (o: Obj) => `${o.name}@${domainName(o.domainId) ?? "invalid"}`;
|
||||||
|
/** Every address in use, primary and alias, across accounts. */
|
||||||
|
const addressTaken = (address: string, except?: string) =>
|
||||||
|
accounts.some((a) => a.id !== except && (addressOf(a) === address || Object.values((a.aliases as Obj) ?? {}).some((al) => `${(al as Obj).name}@${domainName((al as Obj).domainId)}` === address)));
|
||||||
|
|
||||||
|
const view = (o: Obj, properties: unknown): Obj => {
|
||||||
|
const full: Obj = { ...o };
|
||||||
|
if (accounts.includes(o)) full.emailAddress = addressOf(o);
|
||||||
|
if (domains.includes(o)) full.dnsZoneFile = zoneFile(o);
|
||||||
|
if (full.credentials) {
|
||||||
|
full.credentials = Object.fromEntries(Object.entries(full.credentials as Obj).map(([k, c]) => [k, { ...(c as Obj), secret: MASKED }]));
|
||||||
|
}
|
||||||
|
if (!Array.isArray(properties)) return full;
|
||||||
|
const out: Obj = { id: o.id };
|
||||||
|
for (const p of properties as string[]) if (p in full) out[p] = full[p];
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const get = (list: Obj[], perm: string) => (a: Obj) => {
|
||||||
|
demand(perm);
|
||||||
|
const ids = a.ids as string[] | null | undefined;
|
||||||
|
const found = ids ? list.filter((x) => ids.includes(x.id as string)) : list;
|
||||||
|
return { accountId: opts.accountId, state: "1", list: found.map((x) => view(x, a.properties)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A query, filtered only on what the real server indexes for that object.
|
||||||
|
* Any other name is refused the way Stalwart refuses it -- `unsupportedFilter`
|
||||||
|
* with the name as the whole description -- because a mock that took
|
||||||
|
* `{"type": "User"}` let exactly that ship, and the live server answers it
|
||||||
|
* with "unsupportedFilter - type".
|
||||||
|
*/
|
||||||
|
const query = (list: () => Obj[], perm: string, filterable: string[], match: (o: Obj, filter: Obj) => boolean) => (a: Obj) => {
|
||||||
|
demand(perm);
|
||||||
|
const filter = (a.filter as Obj | undefined) ?? {};
|
||||||
|
if ("operator" in filter) throw opts.fail("unsupportedFilter", "Only AND is supported in filters");
|
||||||
|
const unknown = Object.keys(filter).find((k) => !filterable.includes(k));
|
||||||
|
if (unknown) throw opts.fail("unsupportedFilter", unknown);
|
||||||
|
// Stalwart's default order is newest first, by id.
|
||||||
|
const rows = list().filter((o) => match(o, filter)).sort((x, y) => String(y.id).localeCompare(String(x.id), undefined, { numeric: true }));
|
||||||
|
const position = Math.max(0, Number(a.position ?? 0));
|
||||||
|
const limit = a.limit == null ? rows.length : Number(a.limit);
|
||||||
|
return {
|
||||||
|
accountId: opts.accountId,
|
||||||
|
queryState: "1",
|
||||||
|
canCalculateChanges: false,
|
||||||
|
position,
|
||||||
|
ids: rows.slice(position, position + limit).map((o) => o.id),
|
||||||
|
...(a.calculateTotal ? { total: rows.length } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchText = (o: Obj, text: unknown) => {
|
||||||
|
if (typeof text !== "string" || !text.trim()) return true;
|
||||||
|
const needle = text.trim().toLowerCase();
|
||||||
|
return [o.name, o.description, addressOf(o)].some((v) => typeof v === "string" && v.toLowerCase().includes(needle));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setError = (type: string, description: string, properties?: string[]) => ({ type, description, ...(properties ? { properties } : {}) });
|
||||||
|
|
||||||
|
/** The password checks, roughly as strict as a default Stalwart. */
|
||||||
|
const weakPassword = (secret: unknown) => (typeof secret !== "string" || secret.length < 8 ? "Password must be at least 8 characters long." : null);
|
||||||
|
|
||||||
|
/** Stalwart checks a grant against the caller's own permissions. */
|
||||||
|
const grantRefused = (roles: unknown): string | null => {
|
||||||
|
const r = roles as Obj | undefined;
|
||||||
|
if (!r) return null;
|
||||||
|
if (r["@type"] === "Admin" && opts.role !== "admin" && opts.role !== "tenant-admin") return "You are not authorized to grant permissions: administrator.";
|
||||||
|
if (r["@type"] === "Custom") {
|
||||||
|
for (const id of Object.keys((r.roleIds as Obj) ?? {})) {
|
||||||
|
const role = roles_(id);
|
||||||
|
if (!role) return "Role does not exist.";
|
||||||
|
const missing = Object.keys((role.enabledPermissions as Obj) ?? {}).filter((p) => !permissions.has(p));
|
||||||
|
if (missing.length) return `You are not authorized to grant permissions: ${missing.join(", ")}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const roles_ = (id: string) => roles.find((r) => r.id === id);
|
||||||
|
|
||||||
|
const handlers: Record<string, (a: Obj) => Obj> = {
|
||||||
|
"x:Account/get": get(accounts, "sysAccountGet"),
|
||||||
|
"x:Account/query": query(() => accounts, "sysAccountQuery", ["text", "@type", "domainId", "externalId", "memberGroupIds", "memberTenantId", "name"], (o, f) =>
|
||||||
|
(f["@type"] === undefined || o["@type"] === f["@type"]) && (f.domainId === undefined || o.domainId === f.domainId) && matchText(o, f.text) && matchText(o, f.name)),
|
||||||
|
"x:Account/set": (a) => {
|
||||||
|
const created: Obj = {};
|
||||||
|
const notCreated: Obj = {};
|
||||||
|
const updated: Obj = {};
|
||||||
|
const notUpdated: Obj = {};
|
||||||
|
const destroyed: string[] = [];
|
||||||
|
const notDestroyed: Obj = {};
|
||||||
|
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||||
|
demand("sysAccountCreate");
|
||||||
|
const o = { ...(raw as Obj) };
|
||||||
|
if (typeof o.name !== "string" || !/^[a-z0-9._-]+$/i.test(o.name)) { notCreated[cid] = setError("invalidProperties", "Invalid account name.", ["name"]); continue; }
|
||||||
|
if (!domainName(o.domainId)) { notCreated[cid] = setError("invalidForeignKey", "Domain does not exist.", ["domainId"]); continue; }
|
||||||
|
if (addressTaken(`${o.name}@${domainName(o.domainId)}`)) { notCreated[cid] = setError("primaryKeyViolation", "An account or alias with this email address already exists."); continue; }
|
||||||
|
const refused = grantRefused(o.roles);
|
||||||
|
if (refused) { notCreated[cid] = setError("forbidden", refused); continue; }
|
||||||
|
const password = Object.values((o.credentials as Obj) ?? {})[0] as Obj | undefined;
|
||||||
|
const weak = password ? weakPassword(password.secret) : null;
|
||||||
|
if (weak) { notCreated[cid] = setError("invalidProperties", weak, ["secret"]); continue; }
|
||||||
|
const id = `u${counter++}`;
|
||||||
|
accounts.push({ memberGroupIds: {}, aliases: {}, quotas: {}, permissions: { "@type": "Inherit" }, ...o, id, memberTenantId: null, usedDiskQuota: 0, createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), locale: opts.locale, timeZone: null });
|
||||||
|
created[cid] = { id, emailAddress: `${o.name}@${domainName(o.domainId)}` };
|
||||||
|
}
|
||||||
|
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||||
|
demand("sysAccountUpdate");
|
||||||
|
const target = accounts.find((x) => x.id === id);
|
||||||
|
if (!target) { notUpdated[id] = setError("notFound", "Account not found."); continue; }
|
||||||
|
const patch = raw as Obj;
|
||||||
|
const next = structuredClone(target);
|
||||||
|
let failure: Obj | null = null;
|
||||||
|
for (const [path, value] of Object.entries(patch)) {
|
||||||
|
if (path === "id" || path === "@type" || path === "usedDiskQuota" || path === "emailAddress") { failure = setError("invalidProperties", `Property ${path} cannot be changed.`, [path]); break; }
|
||||||
|
if (path.endsWith("/secret")) {
|
||||||
|
const weak = weakPassword(value);
|
||||||
|
if (weak) { failure = setError("invalidProperties", weak, ["secret"]); break; }
|
||||||
|
}
|
||||||
|
if (path.startsWith("credentials/") && value && typeof value === "object") {
|
||||||
|
const weak = weakPassword((value as Obj).secret);
|
||||||
|
if (weak) { failure = setError("invalidProperties", weak, ["secret"]); break; }
|
||||||
|
}
|
||||||
|
setPointer(next, path, value);
|
||||||
|
}
|
||||||
|
if (!failure && ("roles" in patch || "permissions" in patch)) {
|
||||||
|
const refused = grantRefused(next.roles);
|
||||||
|
if (refused) failure = setError("forbidden", refused);
|
||||||
|
}
|
||||||
|
if (!failure) {
|
||||||
|
for (const al of Object.values((next.aliases as Obj) ?? {})) {
|
||||||
|
const address = `${(al as Obj).name}@${domainName((al as Obj).domainId)}`;
|
||||||
|
if (!domainName((al as Obj).domainId)) { failure = setError("invalidForeignKey", "Domain does not exist.", ["aliases"]); break; }
|
||||||
|
if (addressTaken(address, id)) { failure = setError("primaryKeyViolation", "An account or alias with this email address already exists."); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failure) { notUpdated[id] = failure; continue; }
|
||||||
|
// Secrets are stored hashed; the mock just stops echoing them.
|
||||||
|
for (const c of Object.values((next.credentials as Obj) ?? {})) (c as Obj).secret = MASKED;
|
||||||
|
Object.assign(target, next);
|
||||||
|
updated[id] = null;
|
||||||
|
}
|
||||||
|
for (const id of (a.destroy as string[]) ?? []) {
|
||||||
|
demand("sysAccountDestroy");
|
||||||
|
const i = accounts.findIndex((x) => x.id === id);
|
||||||
|
if (i < 0) { notDestroyed[id] = setError("notFound", "Account not found."); continue; }
|
||||||
|
if (accounts[i]!["@type"] === "Group" && accounts.some((x) => (x.memberGroupIds as Obj | undefined)?.[id])) {
|
||||||
|
notDestroyed[id] = { ...setError("objectIsLinked", "Group still has members."), linkedObjects: {} };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
accounts.splice(i, 1);
|
||||||
|
destroyed.push(id);
|
||||||
|
}
|
||||||
|
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
|
||||||
|
},
|
||||||
|
"x:Domain/get": get(domains, "sysDomainGet"),
|
||||||
|
"x:Domain/query": query(() => domains, "sysDomainQuery", ["text", "aliases", "memberTenantId", "name"], (o, f) => matchText(o, f.text) && matchText(o, f.name)),
|
||||||
|
"x:Domain/set": (a) => {
|
||||||
|
const created: Obj = {};
|
||||||
|
const notCreated: Obj = {};
|
||||||
|
const updated: Obj = {};
|
||||||
|
const notUpdated: Obj = {};
|
||||||
|
const destroyed: string[] = [];
|
||||||
|
const notDestroyed: Obj = {};
|
||||||
|
const taken = (name: string, except?: string) => domains.some((d) => d.id !== except && (d.name === name || Object.keys((d.aliases as Obj) ?? {}).includes(name)));
|
||||||
|
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||||
|
demand("sysDomainCreate");
|
||||||
|
const o = raw as Obj;
|
||||||
|
const name = String(o.name ?? "");
|
||||||
|
// Live on 2026-09-13: a reserved TLD is refused by the registry's
|
||||||
|
// domain validator, as invalidPatch with the validator's own words.
|
||||||
|
if (!/^([a-z0-9-]+\.)+[a-z0-9-]{2,}$/.test(name) || /\.(example|test|invalid|localhost)$/.test(name)) { notCreated[cid] = setError("invalidPatch", "Invalid domain name", ["name"]); continue; }
|
||||||
|
if (taken(name)) { notCreated[cid] = setError("primaryKeyViolation", "A domain with this name already exists.", ["name"]); continue; }
|
||||||
|
const id = `d${counter++}`;
|
||||||
|
domains.push(domain(id, name, { ...o, id, createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z") }));
|
||||||
|
// Automatic DKIM, the default, makes its keys straight away.
|
||||||
|
dkimKeys.push({ id: `k${counter++}`, "@type": "Dkim1Ed25519Sha256", domainId: id, selector: "v1-ed25519-20260913", stage: "active", createdAt: new Date().toISOString(), nextTransitionAt: null, memberTenantId: null });
|
||||||
|
created[cid] = { id };
|
||||||
|
}
|
||||||
|
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||||
|
demand("sysDomainUpdate");
|
||||||
|
const target = domains.find((d) => d.id === id);
|
||||||
|
if (!target) { notUpdated[id] = setError("notFound", "Domain not found."); continue; }
|
||||||
|
const next = structuredClone(target);
|
||||||
|
for (const [path, value] of Object.entries(raw as Obj)) setPointer(next, path, value);
|
||||||
|
// Live on 2026-09-13: a catch-all that is not a whole address.
|
||||||
|
if (typeof next.catchAllAddress === "string" && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(next.catchAllAddress)) { notUpdated[id] = setError("invalidPatch", "Invalid email address", ["catchAllAddress"]); continue; }
|
||||||
|
const clash = Object.keys((next.aliases as Obj) ?? {}).find((alias) => alias === next.name || taken(alias, id));
|
||||||
|
if (clash) { notUpdated[id] = setError("primaryKeyViolation", `The name ${clash} is already in use.`, ["aliases"]); continue; }
|
||||||
|
Object.assign(target, next);
|
||||||
|
updated[id] = null;
|
||||||
|
}
|
||||||
|
for (const id of (a.destroy as string[]) ?? []) {
|
||||||
|
demand("sysDomainDestroy");
|
||||||
|
const i = domains.findIndex((d) => d.id === id);
|
||||||
|
if (i < 0) { notDestroyed[id] = setError("notFound", "Domain not found."); continue; }
|
||||||
|
const linked = [
|
||||||
|
...accounts.filter((x) => x.domainId === id || Object.values((x.aliases as Obj) ?? {}).some((al) => (al as Obj).domainId === id)).map((x) => ({ object: "Account", id: x.id })),
|
||||||
|
...dkimKeys.filter((k) => k.domainId === id).map((k) => ({ object: "DkimSignature", id: k.id })),
|
||||||
|
];
|
||||||
|
if (linked.length) { notDestroyed[id] = { ...setError("objectIsLinked", "Object is linked to other objects."), linkedObjects: linked }; continue; }
|
||||||
|
domains.splice(i, 1);
|
||||||
|
destroyed.push(id);
|
||||||
|
}
|
||||||
|
return { accountId: opts.accountId, oldState: "1", newState: "2", created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}), ...(Object.keys(notUpdated).length ? { notUpdated } : {}), ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) };
|
||||||
|
},
|
||||||
|
"x:DkimSignature/get": get(dkimKeys, "sysDkimSignatureGet"),
|
||||||
|
"x:DkimSignature/query": query(() => dkimKeys, "sysDkimSignatureQuery", ["domainId", "memberTenantId"], (o, f) => f.domainId === undefined || o.domainId === f.domainId),
|
||||||
|
"x:DkimSignature/set": (a) => {
|
||||||
|
const destroyed: string[] = [];
|
||||||
|
for (const id of (a.destroy as string[]) ?? []) {
|
||||||
|
demand("sysDkimSignatureDestroy");
|
||||||
|
const i = dkimKeys.findIndex((k) => k.id === id);
|
||||||
|
if (i >= 0) { dkimKeys.splice(i, 1); destroyed.push(id); }
|
||||||
|
}
|
||||||
|
if (a.create) throw opts.fail("forbidden", "The mock does not generate DKIM keys; automatic management does that.");
|
||||||
|
return { accountId: opts.accountId, oldState: "1", newState: "2", created: {}, updated: {}, destroyed };
|
||||||
|
},
|
||||||
|
"x:DnsServer/get": (a) => {
|
||||||
|
demand("sysDnsServerGet");
|
||||||
|
return { accountId: opts.accountId, state: "1", list: ((a.ids as string[]) ?? ["ns1"]).filter((id) => id === "ns1").map((id) => ({ id, "@type": "Cloudflare", description: "Cloudflare (main zone)" })), notFound: [] };
|
||||||
|
},
|
||||||
|
"x:Role/get": get(roles, "sysRoleGet"),
|
||||||
|
"x:Role/query": query(() => roles, "sysRoleQuery", ["text", "description", "memberTenantId"], (o, f) => matchText(o, f.description)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handlers, permissions: [...permissions], accounts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flags(names: string[]): Obj {
|
||||||
|
return Object.fromEntries(names.map((n) => [n, true]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitAddress(address: string): [string, string] {
|
||||||
|
const at = address.lastIndexOf("@");
|
||||||
|
return at < 0 ? [address, "example.com"] : [address.slice(0, at), address.slice(at + 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply one JMAP patch entry. A path walks into nested objects; `null` at the
|
||||||
|
* end removes the key, which is how an alias or a quota is taken away.
|
||||||
|
*/
|
||||||
|
function setPointer(obj: Obj, path: string, value: unknown): void {
|
||||||
|
const parts = path.split("/").map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
|
||||||
|
let node = obj;
|
||||||
|
for (const part of parts.slice(0, -1)) {
|
||||||
|
if (!node[part] || typeof node[part] !== "object") node[part] = {};
|
||||||
|
node = node[part] as Obj;
|
||||||
|
}
|
||||||
|
const last = parts[parts.length - 1]!;
|
||||||
|
if (value === null) delete node[last];
|
||||||
|
else node[last] = value;
|
||||||
|
}
|
||||||
+207
-33
@@ -5,9 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js";
|
||||||
|
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||||
|
import { createDirectory, mockRole } from "./directory.js";
|
||||||
|
|
||||||
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||||
/**
|
/**
|
||||||
@@ -136,13 +138,94 @@ function winmailDat(): Buffer {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
|
/**
|
||||||
|
* A really signed message, served as the raw blob a client verifies against.
|
||||||
|
*
|
||||||
|
* The signature is over exact bytes, so this deliberately does not go through
|
||||||
|
* addEmail: that builds a message out of parts and would hand back a body it
|
||||||
|
* had assembled rather than the one that was signed. Here the blob *is* the
|
||||||
|
* fixture, byte for byte, and the JMAP metadata is arranged around it.
|
||||||
|
*
|
||||||
|
* `bodyStructure` says multipart/signed because that is what the client checks
|
||||||
|
* before deciding to download anything -- a mock that omitted it would leave
|
||||||
|
* the whole path unreachable while every stored byte was still correct.
|
||||||
|
*/
|
||||||
|
function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [string, string]; subject: string; daysAgo: number; mailbox: string; unread?: boolean }) {
|
||||||
|
const id = `e${counter++}`;
|
||||||
|
const raw = signedMessage(o.which);
|
||||||
|
const received = new Date(Date.now() - o.daysAgo * 86400_000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
const body = "The Analytical Engine has no pretensions whatever to originate anything.";
|
||||||
|
const textBlob = putBlob(body, "text/plain");
|
||||||
|
const e: Obj = {
|
||||||
|
id,
|
||||||
|
blobId: putBlob(raw, "message/rfc822"),
|
||||||
|
threadId: `t${id}`,
|
||||||
|
mailboxIds: { [o.mailbox]: true },
|
||||||
|
keywords: o.unread ? {} : { $seen: true },
|
||||||
|
size: raw.length,
|
||||||
|
receivedAt: received,
|
||||||
|
sentAt: received,
|
||||||
|
messageId: [`${id}@mock`],
|
||||||
|
inReplyTo: null,
|
||||||
|
references: null,
|
||||||
|
from: [{ name: o.from[0], email: o.from[1] }],
|
||||||
|
to: [{ name: "Demo User", email: USER }],
|
||||||
|
cc: null, bcc: null, replyTo: null, sender: null,
|
||||||
|
subject: o.subject,
|
||||||
|
hasAttachment: false,
|
||||||
|
preview: body.slice(0, 120),
|
||||||
|
textBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
||||||
|
htmlBody: [],
|
||||||
|
attachments: [],
|
||||||
|
bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } },
|
||||||
|
bodyStructure: {
|
||||||
|
partId: null, blobId: null, size: raw.length, type: "multipart/signed", name: null, charset: null, disposition: null, cid: null,
|
||||||
|
subParts: [
|
||||||
|
{ partId: "1", blobId: textBlob, size: body.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null },
|
||||||
|
{ partId: "2", blobId: null, size: 0, type: "application/x-pkcs7-signature", name: "smime.p7s", charset: null, disposition: "attachment", cid: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
emails.push(e);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A marketing template of the shape #290 was reported against.
|
||||||
|
*
|
||||||
|
* Nothing in it is unusual — an outer 600px wrapper on `bgcolor="#ffffff"`, a
|
||||||
|
* `<style>` block, a coloured call to action, a grey footer — and that is the
|
||||||
|
* point. Every one of those is enough to make `htmlDeclaresColors` true, so a
|
||||||
|
* mock without one could not show what "apply the theme to messages too" does
|
||||||
|
* to the mail people actually receive: nothing at all.
|
||||||
|
*/
|
||||||
|
const STYLED_MARKETING_HTML = `<html><head><style>
|
||||||
|
a { color:#1155CC; text-decoration:underline }
|
||||||
|
.h { font-size:20px; color:#111111 }
|
||||||
|
</style></head><body style="margin:0;background-color:#f4f4f4">
|
||||||
|
<table width="100%" bgcolor="#f4f4f4" cellpadding="0" cellspacing="0"><tr><td align="center">
|
||||||
|
<table width="600" bgcolor="#ffffff" cellpadding="0" cellspacing="0" style="background-color:#ffffff">
|
||||||
|
<tr><td style="padding:24px"><p class="h">Your order is on its way</p>
|
||||||
|
<p style="color:#333333">Thanks for shopping with us. Your parcel left the warehouse this morning.</p>
|
||||||
|
<table cellpadding="0" cellspacing="0"><tr>
|
||||||
|
<td bgcolor="#1155CC" style="border-radius:4px;padding:12px 20px">
|
||||||
|
<a href="https://example.com/track" style="color:#FFFFFF;text-decoration:none">Track your parcel</a>
|
||||||
|
</td></tr></table>
|
||||||
|
<p style="color:#666666;font-size:12px">Order #4471 · placed 2 September</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td bgcolor="#222222" style="padding:16px;color:#dddddd;font-size:12px">
|
||||||
|
You are receiving this because you bought something. <a href="https://example.com/x" style="color:#88bbff">Unsubscribe</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr></table></body></html>`;
|
||||||
|
|
||||||
|
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; styled?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
|
||||||
const id = `e${counter++}`;
|
const id = `e${counter++}`;
|
||||||
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
|
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
|
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
|
||||||
const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag & drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
|
const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag & drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
|
||||||
const textBlob = putBlob(text, "text/plain");
|
const textBlob = putBlob(text, "text/plain");
|
||||||
const htmlBlob = putBlob(html, "text/html");
|
const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html");
|
||||||
const attachments: Obj[] = [];
|
const attachments: Obj[] = [];
|
||||||
if (o.attach) {
|
if (o.attach) {
|
||||||
attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null });
|
attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null });
|
||||||
@@ -162,10 +245,10 @@ function addEmail(o: { from: [string, string]; to?: string; subject: string; day
|
|||||||
from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null,
|
from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null,
|
||||||
subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "),
|
subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "),
|
||||||
textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
||||||
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [],
|
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [],
|
||||||
attachments,
|
attachments,
|
||||||
bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: html, isEncodingProblem: false, isTruncated: false } } : {}) },
|
bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) },
|
||||||
bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] },
|
bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] },
|
||||||
"header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : null,
|
"header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : null,
|
||||||
"header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null,
|
"header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null,
|
||||||
// Stalwart's spam filter writes the SpamAssassin-shaped set at delivery, so
|
// Stalwart's spam filter writes the SpamAssassin-shaped set at delivery, so
|
||||||
@@ -191,7 +274,17 @@ for (let i = 0; i < 45; i++) {
|
|||||||
addEmail({ from: [p[0]!, p[1]!], subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.4, mailbox: "inbox", threadId: e.threadId as string, unread: i % 8 === 0, inReplyTo: `${e.id}@mock`, html: i % 3 === 0 });
|
addEmail({ from: [p[0]!, p[1]!], subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.4, mailbox: "inbox", threadId: e.threadId as string, unread: i % 8 === 0, inReplyTo: `${e.id}@mock`, html: i % 3 === 0 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
addEmail({ from: ["Shop Updates", "[email protected]"], subject: "Your order is on its way", daysAgo: 0.3, mailbox: "inbox", html: true, styled: true });
|
||||||
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
|
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Three signed messages, so every branch of the signature banner can be seen
|
||||||
|
* without staging a certificate authority. Read "A note" first: that pins Ada's
|
||||||
|
* certificate, after which the other two have something to disagree with.
|
||||||
|
*/
|
||||||
|
addSignedEmail({ which: "good", from: ["Ada Lovelace", "[email protected]"], subject: "A note", daysAgo: 0.2, mailbox: "inbox", unread: true });
|
||||||
|
addSignedEmail({ which: "tampered", from: ["Ada Lovelace", "[email protected]"], subject: "A note (altered in transit)", daysAgo: 0.25, mailbox: "inbox", unread: true });
|
||||||
|
addSignedEmail({ which: "imposter", from: ["Ada Lovelace", "[email protected]"], subject: "A note (signed by somebody else)", daysAgo: 0.3, mailbox: "inbox", unread: true });
|
||||||
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
|
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
|
||||||
addEmail({ from: ["Outlook User", "[email protected]"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: true });
|
addEmail({ from: ["Outlook User", "[email protected]"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: true });
|
||||||
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
|
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
|
||||||
@@ -296,6 +389,24 @@ function compareBy(x: Obj, y: Obj, property: string, keyword?: string): number {
|
|||||||
/** A server that does not implement sorting on keywords, so the fallback can be developed against. */
|
/** A server that does not implement sorting on keywords, so the fallback can be developed against. */
|
||||||
const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1";
|
const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1";
|
||||||
|
|
||||||
|
/** The floor Stalwart puts under a requested EventSource ping interval. */
|
||||||
|
const PING_FLOOR_SECONDS = 30;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An account that may not send calendar invitations.
|
||||||
|
*
|
||||||
|
* 0.16.21 rejects a `CalendarEvent/set` that asks for scheduling messages when
|
||||||
|
* the account lacks the `calendarSchedulingSend` permission, rather than
|
||||||
|
* accepting the write and quietly sending nothing. **Confirmed live on 0.16.21
|
||||||
|
* (2026-09-06)** against an account holding a role with that permission
|
||||||
|
* disabled: `sendSchedulingMessages: true` came back `notCreated` with
|
||||||
|
* `forbidden` and the text below, while the identical request with the flag
|
||||||
|
* false was created normally. Set MOCK_NO_SCHEDULING_SEND=1 to develop against
|
||||||
|
* that account.
|
||||||
|
*/
|
||||||
|
const NO_SCHEDULING_SEND = process.env.MOCK_NO_SCHEDULING_SEND === "1";
|
||||||
|
const SCHEDULING_FORBIDDEN = "This account is not allowed to send calendar scheduling messages.";
|
||||||
|
|
||||||
const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
|
const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
|
||||||
/** One per contact, by index; a gap means that card has no birthday. */
|
/** One per contact, by index; a gap means that card has no birthday. */
|
||||||
const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [
|
const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [
|
||||||
@@ -480,12 +591,19 @@ function enforceLimits(name: string, args: Obj): void {
|
|||||||
const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
|
const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Stalwart does not return `shareWith` unless a client asks for it by name: a
|
* `Mailbox/get` does not return `shareWith` unless a client asks for it by
|
||||||
* `/get` with no `properties` comes back without the field at all. Confirmed on
|
* name: a `/get` with no `properties` comes back without the field at all.
|
||||||
* 0.16.19 (2026-08-27) against a calendar and an address book that really were
|
* Confirmed on 0.16.19 (2026-08-27) against a mailbox that really was shared.
|
||||||
* shared. The mock handing it over unasked meant a client that never asked
|
* The mock handing it over unasked meant a client that never asked still saw
|
||||||
* still saw every share, and the one place that did not -- the real server --
|
* every share, and the one place that did not -- the real server -- showed
|
||||||
* showed nothing shared at all.
|
* nothing shared at all.
|
||||||
|
*
|
||||||
|
* Calendars and address books used to behave the same way and no longer do.
|
||||||
|
* 0.16.21 fixed `Calendar/get` and `AddressBook/get` to return every property
|
||||||
|
* when `properties` is omitted or null, `shareWith` included. **Confirmed live
|
||||||
|
* on 0.16.21 (2026-09-06):** both come back with the full set, while
|
||||||
|
* `Mailbox/get` on the same server still omits it — so this stays, and it
|
||||||
|
* stays applied to mailboxes alone.
|
||||||
*/
|
*/
|
||||||
function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
|
function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
|
||||||
if (a.properties) return res;
|
if (a.properties) return res;
|
||||||
@@ -502,9 +620,9 @@ function genericGet(list: Obj[]) {
|
|||||||
/**
|
/**
|
||||||
* An id, as either a stored event or one occurrence of one.
|
* An id, as either a stored event or one occurrence of one.
|
||||||
*
|
*
|
||||||
* A synthetic id whose base is gone, or whose index falls outside the series
|
* A synthetic id whose base is gone, or whose date the rule no longer
|
||||||
* (deleted, or past a `count`), resolves to nothing — `notFound`, the way the
|
* generates (excluded, or past a `count`), resolves to nothing — `notFound`,
|
||||||
* server answers for an occurrence that is not there any more.
|
* the way the server answers for an occurrence that is not there any more.
|
||||||
*/
|
*/
|
||||||
function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
|
function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
|
||||||
const direct = list.find((x) => x.id === id);
|
const direct = list.find((x) => x.id === id);
|
||||||
@@ -513,7 +631,7 @@ function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence }
|
|||||||
if (!parsed) return null;
|
if (!parsed) return null;
|
||||||
const base = list.find((x) => x.id === parsed.baseId);
|
const base = list.find((x) => x.id === parsed.baseId);
|
||||||
if (!base) return null;
|
if (!base) return null;
|
||||||
const occ = occurrenceAt(base, parsed.slot);
|
const occ = occurrenceAt(base, parsed.recurrenceId);
|
||||||
return occ ? { base, occ } : null;
|
return occ ? { base, occ } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,6 +753,27 @@ function calendarEventSet(a: Obj) {
|
|||||||
const notUpdated: Obj = {};
|
const notUpdated: Obj = {};
|
||||||
const notDestroyed: Obj = {};
|
const notDestroyed: Obj = {};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An account that may not send invitations refuses the whole request the
|
||||||
|
* moment it asks for them, and refuses it per object rather than as a method
|
||||||
|
* error. Confirmed live on 0.16.21 for all three of create, update and
|
||||||
|
* destroy; the same requests with the flag absent or false went through.
|
||||||
|
* The flag alone decides it — the server does not first check whether the
|
||||||
|
* event has anyone to notify.
|
||||||
|
*/
|
||||||
|
if (NO_SCHEDULING_SEND && a.sendSchedulingMessages === true) {
|
||||||
|
const denied = () => new SetError("forbidden", SCHEDULING_FORBIDDEN).toJSON();
|
||||||
|
for (const cid of Object.keys((a.create as Obj) ?? {})) notCreated[cid] = denied();
|
||||||
|
for (const id of Object.keys((a.update as Obj) ?? {})) notUpdated[id] = denied();
|
||||||
|
for (const id of ((a.destroy as string[]) ?? [])) notDestroyed[id] = denied();
|
||||||
|
return setResp({
|
||||||
|
created, updated, destroyed,
|
||||||
|
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
||||||
|
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
||||||
|
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||||
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
|
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
|
||||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||||
@@ -747,6 +886,15 @@ function matchSubmissionFilter(sub: Obj, f: Obj | undefined): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Who the demo user is, for administration. See mock/directory.ts. */
|
||||||
|
const directory = createDirectory({
|
||||||
|
accountId: ACCOUNT,
|
||||||
|
user: USER,
|
||||||
|
locale: MOCK_LOCALE,
|
||||||
|
role: mockRole(process.env.MOCK_ROLE),
|
||||||
|
fail: (type, description) => new MethodError(type, description),
|
||||||
|
});
|
||||||
|
|
||||||
const handlers: Record<string, Handler> = {
|
const handlers: Record<string, Handler> = {
|
||||||
// 0.16 exposes the account locale here, under a permission ordinary users
|
// 0.16 exposes the account locale here, under a permission ordinary users
|
||||||
// actually have (unlike x:Account below, which needs sysAccountGet).
|
// actually have (unlike x:Account below, which needs sysAccountGet).
|
||||||
@@ -755,12 +903,10 @@ const handlers: Record<string, Handler> = {
|
|||||||
const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null }));
|
const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null }));
|
||||||
return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") };
|
return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") };
|
||||||
},
|
},
|
||||||
// Stalwart's directory extension - the client reads the account locale from here.
|
// Stalwart's directory registry: accounts, domains and roles, behind the
|
||||||
"x:Account/get": (a) => {
|
// same permissions as the real thing. The locale fallback reads x:Account
|
||||||
const ids = (a.ids as string[] | null) ?? [ACCOUNT];
|
// too, and is refused here exactly when a real server would refuse it.
|
||||||
const list = ids.filter((id) => id === ACCOUNT).map((id) => ({ id, name: USER, locale: MOCK_LOCALE, timeZone: null }));
|
...directory.handlers,
|
||||||
return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => id !== ACCOUNT) };
|
|
||||||
},
|
|
||||||
"Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never,
|
"Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never,
|
||||||
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
|
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
|
||||||
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
||||||
@@ -1092,7 +1238,7 @@ const handlers: Record<string, Handler> = {
|
|||||||
"SieveScript/get": genericGet(sieveScripts),
|
"SieveScript/get": genericGet(sieveScripts),
|
||||||
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
|
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
|
||||||
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
||||||
"Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never,
|
"Calendar/get": (a) => genericGet(calendarsFor(a.accountId))(a),
|
||||||
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
|
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
|
||||||
/*
|
/*
|
||||||
* With `expandRecurrences` every id that comes back is synthetic — a one-off
|
* With `expandRecurrences` every id that comes back is synthetic — a one-off
|
||||||
@@ -1111,21 +1257,23 @@ const handlers: Record<string, Handler> = {
|
|||||||
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
|
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
|
||||||
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
|
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, slotOfOccurrence(e, occ)));
|
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, occ.recurrenceId));
|
||||||
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
|
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
|
||||||
},
|
},
|
||||||
"CalendarEvent/get": (a) => {
|
"CalendarEvent/get": (a) => {
|
||||||
const list = eventsFor(a.accountId);
|
const list = eventsFor(a.accountId);
|
||||||
const ids = a.ids as string[] | null | undefined;
|
const ids = a.ids as string[] | null | undefined;
|
||||||
if (!ids) return genericGet(list)(a);
|
const properties = a.properties as string[] | null | undefined;
|
||||||
|
// With no ids every event comes back under its stored id, none synthetic.
|
||||||
|
if (!ids) return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => eventGetView(x, false, properties)), notFound: [] };
|
||||||
const found: Obj[] = [];
|
const found: Obj[] = [];
|
||||||
const notFound: string[] = [];
|
const notFound: string[] = [];
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const resolved = resolveEvent(list, id);
|
const resolved = resolveEvent(list, id);
|
||||||
if (!resolved) { notFound.push(id); continue; }
|
if (!resolved) { notFound.push(id); continue; }
|
||||||
found.push(resolved.occ ? occurrenceView(resolved.base, resolved.occ) : resolved.base);
|
found.push(resolved.occ ? eventGetView(occurrenceView(resolved.base, resolved.occ), true, properties) : eventGetView(resolved.base, false, properties));
|
||||||
}
|
}
|
||||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound };
|
return { accountId: ACCOUNT, state: String(state.n), list: found, notFound };
|
||||||
},
|
},
|
||||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||||
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
||||||
@@ -1149,7 +1297,7 @@ const handlers: Record<string, Handler> = {
|
|||||||
}
|
}
|
||||||
return { accountId: ACCOUNT, list };
|
return { accountId: ACCOUNT, list };
|
||||||
},
|
},
|
||||||
"AddressBook/get": (a) => hideShareWithUnlessAsked(a, genericGet(booksFor(a.accountId))(a) as { list: Obj[] }) as never,
|
"AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a),
|
||||||
"AddressBook/set": (a) => {
|
"AddressBook/set": (a) => {
|
||||||
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
|
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
|
||||||
included -- "You are not allowed to modify this address book", confirmed
|
included -- "You are not allowed to modify this address book", confirmed
|
||||||
@@ -1165,6 +1313,8 @@ const handlers: Record<string, Handler> = {
|
|||||||
return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a);
|
return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a);
|
||||||
},
|
},
|
||||||
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
|
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
|
||||||
|
// An empty `properties` list returns `id` alone, which `pick` already does.
|
||||||
|
// 0.16.22 made Stalwart agree; through 0.16.21 it returned every property.
|
||||||
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
|
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
|
||||||
"ContactCard/set": genericSet(cards, "cc"),
|
"ContactCard/set": genericSet(cards, "cc"),
|
||||||
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||||
@@ -1275,7 +1425,7 @@ export const server = createServer(async (req, res) => {
|
|||||||
// The account info endpoint; the only place a server reports its edition.
|
// The account info endpoint; the only place a server reports its edition.
|
||||||
if (url.pathname === "/api/account" && req.method === "GET") {
|
if (url.pathname === "/api/account" && req.method === "GET") {
|
||||||
res.writeHead(200, { "content-type": "application/json" });
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE }));
|
return res.end(JSON.stringify({ permissions: directory.permissions, edition: "oss", locale: MOCK_LOCALE }));
|
||||||
}
|
}
|
||||||
if (url.pathname === "/jmap/" && req.method === "POST") {
|
if (url.pathname === "/jmap/" && req.method === "POST") {
|
||||||
const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][]; using?: string[] };
|
const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][]; using?: string[] };
|
||||||
@@ -1331,13 +1481,37 @@ export const server = createServer(async (req, res) => {
|
|||||||
res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length });
|
res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length });
|
||||||
return res.end(b.data);
|
return res.end(b.data);
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* The `ping` query parameter, and what comes back for it.
|
||||||
|
*
|
||||||
|
* **Confirmed live on 0.16.21 (2026-09-06):** the interval is in **seconds**
|
||||||
|
* — `data: {"interval": 30}` — where up to 0.16.20 the same field carried
|
||||||
|
* milliseconds. The server floors it at 30 s (asking for 1, 2 or 5 all
|
||||||
|
* answered 30 and pinged every 30 s) and honours anything above (45 pinged
|
||||||
|
* at 45 s and said 45, 60 at 60 and said 60). `ping=0` disables pings
|
||||||
|
* altogether; a value that is not a number at all — `abc`, or empty — is a
|
||||||
|
* 400 before the stream opens.
|
||||||
|
*
|
||||||
|
* The first ping arrives one whole interval in, not on connect, so nothing
|
||||||
|
* is written here: `flushHeaders` opens the stream on its own. A mock that
|
||||||
|
* pinged immediately would let a client treat the first ping as an
|
||||||
|
* connection-established signal and hang forever against the real thing.
|
||||||
|
*/
|
||||||
if (url.pathname.startsWith("/jmap/eventsource")) {
|
if (url.pathname.startsWith("/jmap/eventsource")) {
|
||||||
|
const raw = url.searchParams.get("ping");
|
||||||
|
const asked = Number(raw);
|
||||||
|
if (raw === null || raw === "" || !Number.isInteger(asked) || asked < 0) {
|
||||||
|
res.writeHead(400, { "content-type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({ type: "urn:ietf:params:jmap:error:notRequest", status: 400 }));
|
||||||
|
}
|
||||||
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
||||||
res.write(`event: ping\ndata: {}\n\n`);
|
res.flushHeaders();
|
||||||
sseClients.add(res);
|
sseClients.add(res);
|
||||||
const t = setInterval(() => res.write(`event: ping\ndata: {}\n\n`), 25000);
|
const interval = asked === 0 ? 0 : Math.max(asked, PING_FLOOR_SECONDS);
|
||||||
req.on("close", () => { clearInterval(t); sseClients.delete(res); });
|
const t = interval
|
||||||
// Simulate a new message every 90s
|
? setInterval(() => res.write(`event: ping\ndata: {"interval": ${interval}}\n\n`), interval * 1000)
|
||||||
|
: null;
|
||||||
|
req.on("close", () => { if (t) clearInterval(t); sseClients.delete(res); });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.writeHead(404, { "content-type": "application/json" });
|
res.writeHead(404, { "content-type": "application/json" });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The mock expands recurrences so that per-occurrence editing can be developed
|
* The mock expands recurrences so that per-occurrence editing can be developed
|
||||||
@@ -68,9 +68,9 @@ describe("expandOccurrences", () => {
|
|||||||
describe("occurrenceView", () => {
|
describe("occurrenceView", () => {
|
||||||
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
|
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
|
||||||
const base = series();
|
const base = series();
|
||||||
const occ = occurrenceAt(base, 1)!;
|
const occ = occurrenceAt(base, "2026-09-08T09:00:00")!;
|
||||||
const view = occurrenceView(base, occ);
|
const view = occurrenceView(base, occ);
|
||||||
assert.equal(view.id, syntheticId("ev1", 1));
|
assert.equal(view.id, syntheticId("ev1", "2026-09-08T09:00:00"));
|
||||||
assert.equal(view.baseEventId, "ev1");
|
assert.equal(view.baseEventId, "ev1");
|
||||||
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
||||||
assert.equal(view.recurrenceRule, undefined);
|
assert.equal(view.recurrenceRule, undefined);
|
||||||
@@ -81,8 +81,8 @@ describe("occurrenceView", () => {
|
|||||||
// Both halves matter. The id is why `baseEventId` proves nothing about a
|
// Both halves matter. The id is why `baseEventId` proves nothing about a
|
||||||
// series; the absent `recurrenceId` is why a one-off does not read as one.
|
// series; the absent `recurrenceId` is why a one-off does not read as one.
|
||||||
const base = oneOff();
|
const base = oneOff();
|
||||||
const view = occurrenceView(base, occurrenceAt(base, 0)!);
|
const view = occurrenceView(base, occurrenceAt(base, "2026-09-08T12:00:00")!);
|
||||||
assert.equal(view.id, "ev2-o0");
|
assert.equal(view.id, "ev2-r20260908T120000");
|
||||||
assert.equal(view.baseEventId, "ev2");
|
assert.equal(view.baseEventId, "ev2");
|
||||||
assert.notEqual(view.id, view.baseEventId);
|
assert.notEqual(view.id, view.baseEventId);
|
||||||
assert.equal(view.recurrenceId, undefined);
|
assert.equal(view.recurrenceId, undefined);
|
||||||
@@ -90,21 +90,73 @@ describe("occurrenceView", () => {
|
|||||||
|
|
||||||
it("lets an override win over the series", () => {
|
it("lets an override win over the series", () => {
|
||||||
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
|
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
|
||||||
// Slot 2, not 1: one override has already shifted the numbering. Reaching
|
// The same recurrence id as before the override was written, because that
|
||||||
// for the id this occurrence had *before* the write is the bug below.
|
// is now the whole point: the write does not move any other occurrence.
|
||||||
const view = occurrenceView(base, occurrenceAt(base, 2)!);
|
const view = occurrenceView(base, occurrenceAt(base, "2026-09-08T09:00:00")!);
|
||||||
assert.equal(view.start, "2026-09-08T09:00:00");
|
assert.equal(view.start, "2026-09-08T09:00:00");
|
||||||
assert.equal(view.title, "Moved");
|
assert.equal(view.title, "Moved");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("eventGetView", () => {
|
||||||
|
/*
|
||||||
|
* What 0.16.22 changed in `CalendarEvent/get`, read from its source and the
|
||||||
|
* tests that came with it (`tests/src/jmap/calendar/event.rs` and
|
||||||
|
* `instance.rs`).
|
||||||
|
*/
|
||||||
|
it("reports no base for an event read by its stored id", () => {
|
||||||
|
// 0.16.21 answered with the event's own id here.
|
||||||
|
assert.deepEqual(eventGetView(oneOff(), false, ["id", "baseEventId"]), { id: "ev2", baseEventId: null });
|
||||||
|
assert.equal(eventGetView(series(), false, ["baseEventId"]).baseEventId, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still gives a one-off read through its synthetic id a base", () => {
|
||||||
|
// An expanded query hands a one-off a synthetic id, so this has not
|
||||||
|
// changed: `baseEventId` is still no evidence of a series.
|
||||||
|
const base = oneOff();
|
||||||
|
const view = eventGetView(occurrenceView(base, occurrenceAt(base, "2026-09-08T12:00:00")!), true, ["baseEventId"]);
|
||||||
|
assert.equal(view.baseEventId, "ev2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers null for the rule and overrides named on an occurrence", () => {
|
||||||
|
const base = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Standup (long)" } } };
|
||||||
|
const view = eventGetView(occurrenceView(base, occurrenceAt(base, "2026-09-08T09:00:00")!), true,
|
||||||
|
["recurrenceId", "recurrenceRule", "recurrenceOverrides"]);
|
||||||
|
assert.deepEqual(view, { id: "ev1-r20260908T090000", recurrenceId: "2026-09-08T09:00:00", recurrenceRule: null, recurrenceOverrides: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the rule on the series itself alone", () => {
|
||||||
|
assert.deepEqual(eventGetView(series(), false, ["recurrenceRule"]).recurrenceRule, WEEKDAYS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads useDefaultAlerts as false until it is set", () => {
|
||||||
|
// It used to read true until set.
|
||||||
|
assert.equal(eventGetView(series(), false, ["useDefaultAlerts"]).useDefaultAlerts, false);
|
||||||
|
assert.equal(eventGetView({ ...series(), useDefaultAlerts: true }, false, ["useDefaultAlerts"]).useDefaultAlerts, true);
|
||||||
|
assert.equal(eventGetView({ ...series(), useDefaultAlerts: false }, false, ["useDefaultAlerts"]).useDefaultAlerts, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only the id for an empty list", () => {
|
||||||
|
// 0.16.21 treated an empty list as asking for everything.
|
||||||
|
assert.deepEqual(eventGetView(series(), false, []), { id: "ev1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the object unchanged when no list is given", () => {
|
||||||
|
assert.deepEqual(eventGetView(series(), false, null), series());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("parseSyntheticId", () => {
|
describe("parseSyntheticId", () => {
|
||||||
it("round-trips", () => {
|
it("round-trips", () => {
|
||||||
assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", slot: 12 });
|
assert.deepEqual(parseSyntheticId(syntheticId("ev1", "2026-09-08T09:00:00")),
|
||||||
|
{ baseId: "ev1", recurrenceId: "2026-09-08T09:00:00" });
|
||||||
});
|
});
|
||||||
it("does not claim a stored id", () => {
|
it("does not claim a stored id", () => {
|
||||||
assert.equal(parseSyntheticId("ev1"), null);
|
assert.equal(parseSyntheticId("ev1"), null);
|
||||||
});
|
});
|
||||||
|
it("does not claim an id that merely ends in digits", () => {
|
||||||
|
assert.equal(parseSyntheticId("ev1-r2026"), null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("splitOccurrencePatch", () => {
|
describe("splitOccurrencePatch", () => {
|
||||||
@@ -135,35 +187,47 @@ describe("splitOccurrencePatch", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe("synthetic ids are only true until the next write", () => {
|
describe("synthetic ids survive a write", () => {
|
||||||
/*
|
/*
|
||||||
* Confirmed live on 0.16.20 (2026-08-31): writing one `recurrenceOverrides`
|
* This used to assert the opposite, and the reversal is the point.
|
||||||
* entry renumbered a five-week series so that the *same* ids addressed
|
*
|
||||||
* different dates. Nothing was rejected. The mock reproduces the shape of
|
* Up to 0.16.20 a synthetic id encoded a position, so writing one override
|
||||||
* that rather than the exact permutation, because the property that bites is
|
* renumbered the series and a held id silently began naming a different
|
||||||
* not which date an id moves to but that it moves at all, silently.
|
* date — confirmed live on 2026-08-31, and reproduced here on purpose so a
|
||||||
|
* client could not be written against a comfort the server did not offer.
|
||||||
|
*
|
||||||
|
* 0.16.21 identifies an occurrence by its recurrence id instead.
|
||||||
|
* **Confirmed live on 0.16.21 (2026-09-06):** a five-week series was
|
||||||
|
* expanded, its third occurrence retitled through the synthetic id, and all
|
||||||
|
* five original ids re-read. Every one resolved, and every one still named
|
||||||
|
* its own date. So the hazard is gone, and the mock stops teaching it.
|
||||||
*/
|
*/
|
||||||
it("makes a cached id address a different date after an override is written", () => {
|
it("keeps a cached id on the same date after an override is written", () => {
|
||||||
const before = series();
|
const before = series();
|
||||||
const held = syntheticId("ev1", slotOfOccurrence(before, occurrenceAt(before, 3)!));
|
const held = syntheticId("ev1", occurrenceAt(before, "2026-09-10T09:00:00")!.recurrenceId);
|
||||||
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.slot)!.start;
|
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.recurrenceId)!.start;
|
||||||
|
|
||||||
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
||||||
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.slot)!.start;
|
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.recurrenceId)!.start;
|
||||||
|
|
||||||
assert.notEqual(dateAfter, dateBefore);
|
assert.equal(dateAfter, dateBefore);
|
||||||
// And crucially it still resolves — a stale id is wrong, not invalid, so a
|
|
||||||
// client that trusts it gets a confident answer about the wrong day.
|
|
||||||
assert.ok(dateAfter);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps recurrenceId meaning the same date across a write, which is why it is the handle", () => {
|
it("resolves every id of a series after one of them is overridden", () => {
|
||||||
const before = series();
|
const before = series();
|
||||||
const occ = occurrenceAt(before, 3)!;
|
const held = expandOccurrences(before, new Date("2026-09-07T00:00:00"), new Date("2026-09-12T00:00:00"))
|
||||||
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
.map((o) => syntheticId("ev1", o.recurrenceId));
|
||||||
const same = expandOccurrences(after, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"))
|
const after = { ...before, recurrenceOverrides: { "2026-09-09T09:00:00": { title: "changed" } } };
|
||||||
.find((o) => o.recurrenceId === occ.recurrenceId);
|
for (const id of held) {
|
||||||
assert.equal(same!.start, occ.start);
|
const occ = occurrenceAt(after, parseSyntheticId(id)!.recurrenceId);
|
||||||
|
assert.ok(occ, `${id} should still resolve`);
|
||||||
|
assert.equal(syntheticId("ev1", occ.recurrenceId), id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refuses an id whose date the rule no longer generates", () => {
|
||||||
|
const base = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { excluded: true } } };
|
||||||
|
assert.equal(occurrenceAt(base, "2026-09-09T09:00:00"), null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.20.
|
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.22.
|
||||||
*
|
*
|
||||||
* The mock used to hand a recurring event back once, as its stored self. Three
|
* The mock used to hand a recurring event back once, as its stored self. Three
|
||||||
* things that only a live server showed were therefore impossible to develop
|
* things that only a live server showed were therefore impossible to develop
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
* - an expanded query gives *everything* a synthetic id over a `baseEventId`,
|
* - an expanded query gives *everything* a synthetic id over a `baseEventId`,
|
||||||
* a one-off included, so `baseEventId` is no evidence of a series;
|
* a one-off included, so `baseEventId` is no evidence of a series;
|
||||||
* - an occurrence carries a `recurrenceId` and no rule of its own;
|
* - an occurrence carries a `recurrenceId` and no rule of its own;
|
||||||
* - 0.16.20 takes a write aimed at a synthetic id and turns it into a
|
* - a write aimed at a synthetic id becomes a `recurrenceOverrides` entry
|
||||||
* `recurrenceOverrides` entry rather than touching the series.
|
* rather than touching the series.
|
||||||
*
|
*
|
||||||
* A mock that agrees with the client rather than with the server is how #26 and
|
* A mock that agrees with the client rather than with the server is how #26 and
|
||||||
* #30 reached a live instance, so the refusals matter as much as the successes:
|
* #30 reached a live instance, so the refusals matter as much as the successes:
|
||||||
@@ -25,41 +25,41 @@ const MAX_ITERATIONS = 750;
|
|||||||
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
|
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The id an occurrence is addressed by, which is only true until the next write.
|
* The id an occurrence is addressed by: its `recurrenceId`, not its position.
|
||||||
*
|
*
|
||||||
* Stalwart's are opaque; the mock's are parseable because it has to resolve
|
* Stalwart's are opaque; the mock's are parseable because it has to resolve
|
||||||
* them, and nothing in ihasmail may read either.
|
* them, and nothing in ihasmail may read either.
|
||||||
*
|
*
|
||||||
* They are also deliberately **unstable**, because the real ones are.
|
* **They are stable, and that is a change.** Up to 0.16.20 a synthetic id
|
||||||
* **Confirmed live on 0.16.20 (2026-08-31):** a synthetic id encodes a position
|
* encoded a *position* in the expanded series, so writing one override
|
||||||
* in the expanded series, and writing a `recurrenceOverrides` entry adds a
|
* renumbered the rest and a held id silently began addressing a different
|
||||||
* component that renumbers it. A five-week series held `e i m q u` over
|
* date — a hazard this file used to reproduce on purpose. 0.16.21 fixed it:
|
||||||
* 03-01…03-29; after one override was written to 03-08 the same ids addressed
|
* an occurrence is now identified by its recurrence id.
|
||||||
* 03-01, 03-15, 03-29, 03-08, 03-22. Nothing was rejected — they just meant
|
|
||||||
* different dates.
|
|
||||||
*
|
*
|
||||||
* That is the hazard worth reproducing, and note which way round it goes: a
|
* **Confirmed live on 0.16.21 (2026-09-06):** a five-week weekly series was
|
||||||
* stale id is not *invalid*, it is *wrong*. A mock that expired them instead
|
* expanded, the third occurrence retitled through its synthetic id, and all
|
||||||
* would hand back a loud `notFound` and let a client that caches ids look
|
* five original ids re-read afterwards. Every one still resolved, and every
|
||||||
* careful. So the numbering is shifted by the number of overrides — an
|
* one still named its own date; nothing was renumbered and nothing was
|
||||||
* arbitrary stand-in for Stalwart's renumbering, with the one property that
|
* `notFound`. Only the *order* of the ids from an expanded query changed —
|
||||||
* matters: hold an id across a write and it silently addresses another date.
|
* the overridden occurrence moved to the end of the list — which is why a
|
||||||
|
* client sorts by `start` rather than trusting query order.
|
||||||
|
*
|
||||||
|
* The real ids look nothing like these (`h1fo9uaaaaab` for the first of that
|
||||||
|
* series); what has to match is that holding one across a write stays correct.
|
||||||
*/
|
*/
|
||||||
export const syntheticId = (baseId: string, slot: number): string => `${baseId}-o${slot}`;
|
const compact = (recurrenceId: string): string => recurrenceId.replace(/[-:]/g, "");
|
||||||
|
|
||||||
export function parseSyntheticId(id: string): { baseId: string; slot: number } | null {
|
export const syntheticId = (baseId: string, recurrenceId: string): string =>
|
||||||
const m = /^(.+)-o(\d+)$/.exec(id);
|
`${baseId}-r${compact(recurrenceId)}`;
|
||||||
return m ? { baseId: m[1]!, slot: Number(m[2]) } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How far the id numbering has been rotated away from the series order. */
|
export function parseSyntheticId(id: string): { baseId: string; recurrenceId: string } | null {
|
||||||
function rotation(base: Obj): number {
|
const m = /^(.+)-r(\d{8}T\d{6})$/.exec(id);
|
||||||
return Object.keys((base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {}).length;
|
if (!m) return null;
|
||||||
}
|
const c = m[2]!;
|
||||||
|
const recurrenceId =
|
||||||
/** The id slot this occurrence currently answers to. */
|
`${c.slice(0, 4)}-${c.slice(4, 6)}-${c.slice(6, 8)}` +
|
||||||
export function slotOfOccurrence(base: Obj, occ: Occurrence): number {
|
`T${c.slice(9, 11)}:${c.slice(11, 13)}:${c.slice(13, 15)}`;
|
||||||
return occ.index + rotation(base);
|
return { baseId: m[1]!, recurrenceId };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
|
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
|
||||||
@@ -104,8 +104,8 @@ export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[]
|
|||||||
const emit = (index: number, at: Date): boolean => {
|
const emit = (index: number, at: Date): boolean => {
|
||||||
const recurrenceId = localDateTime(at);
|
const recurrenceId = localDateTime(at);
|
||||||
const override = overrides[recurrenceId];
|
const override = overrides[recurrenceId];
|
||||||
// An excluded date is simply gone from the expansion. Its slot is not
|
// An excluded date is simply gone from the expansion. Nothing is
|
||||||
// reserved -- see `syntheticId` for why nothing here pretends otherwise.
|
// reserved in its place, and no other occurrence's id moves because of it.
|
||||||
if (override?.excluded === true) return true;
|
if (override?.excluded === true) return true;
|
||||||
/*
|
/*
|
||||||
* An override may move the occurrence, and then `start` and `recurrenceId`
|
* An override may move the occurrence, and then `start` and `recurrenceId`
|
||||||
@@ -115,9 +115,9 @@ export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[]
|
|||||||
* came back `start: 2027-06-14T14:00:00` with `recurrenceId` still
|
* came back `start: 2027-06-14T14:00:00` with `recurrenceId` still
|
||||||
* `2027-06-14T09:00:00`.
|
* `2027-06-14T09:00:00`.
|
||||||
*
|
*
|
||||||
* Which is exactly why `recurrenceId` is what a client holds on to. It is
|
* Which is exactly why `recurrenceId` is what a client holds on to, and
|
||||||
* the one name for this instance that neither a renumbering nor a move
|
* since 0.16.21 what the id is built from: the one name for this instance
|
||||||
* changes.
|
* that a move does not change.
|
||||||
*/
|
*/
|
||||||
const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId;
|
const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId;
|
||||||
const shown = parseLocal(start);
|
const shown = parseLocal(start);
|
||||||
@@ -178,7 +178,7 @@ export function occurrenceView(base: Obj, occ: Occurrence): Obj {
|
|||||||
const view: Obj = { ...base };
|
const view: Obj = { ...base };
|
||||||
for (const k of SERIES_ONLY) delete view[k];
|
for (const k of SERIES_ONLY) delete view[k];
|
||||||
Object.assign(view, occ.override ?? {});
|
Object.assign(view, occ.override ?? {});
|
||||||
view.id = syntheticId(base.id as string, slotOfOccurrence(base, occ));
|
view.id = syntheticId(base.id as string, occ.recurrenceId);
|
||||||
view.baseEventId = base.id;
|
view.baseEventId = base.id;
|
||||||
view.start = occ.start;
|
view.start = occ.start;
|
||||||
// Only a genuine instance of a series carries one. A one-off expanded into
|
// Only a genuine instance of a series carries one. A one-off expanded into
|
||||||
@@ -188,6 +188,43 @@ export function occurrenceView(base: Obj, occ: Occurrence): Obj {
|
|||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Series properties a synthetic id answers `null` for, when they are named. */
|
||||||
|
const NULL_ON_OCCURRENCE = new Set(["recurrenceRule", "recurrenceOverrides"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object a `CalendarEvent/get` with a `properties` list returns, as 0.16.22
|
||||||
|
* builds it. Omitted or null `properties` returns the stored object unchanged.
|
||||||
|
*
|
||||||
|
* Three of the named properties are no longer read off the object:
|
||||||
|
*
|
||||||
|
* - `baseEventId` is the master's id on a synthetic id and `null` on anything
|
||||||
|
* else. Through 0.16.21 an event read by its stored id reported that id as
|
||||||
|
* its own base. An expanded query still hands a one-off a synthetic id, so
|
||||||
|
* one read that way still carries a base, and `baseEventId` is still no
|
||||||
|
* evidence of a series;
|
||||||
|
* - `recurrenceRule` and `recurrenceOverrides` come back as `null` on a
|
||||||
|
* synthetic id rather than being left out;
|
||||||
|
* - `useDefaultAlerts` is the reader's own preference, and `false` when they
|
||||||
|
* never set one. It used to read `true` until set. The mock has one reader,
|
||||||
|
* so a value stored on the event stands in for that reader's.
|
||||||
|
*
|
||||||
|
* An empty list returns `id` alone, where 0.16.21 treated it as asking for
|
||||||
|
* everything. `ContactCard/get` changed the same way.
|
||||||
|
*
|
||||||
|
* Read from the 0.16.22 source (`calendar_event/get.rs`) and its tests.
|
||||||
|
*/
|
||||||
|
export function eventGetView(event: Obj, synthetic: boolean, properties: string[] | null | undefined): Obj {
|
||||||
|
if (!properties) return event;
|
||||||
|
const out: Obj = { id: event.id };
|
||||||
|
for (const p of properties) {
|
||||||
|
if (p === "baseEventId") out[p] = synthetic ? event.baseEventId : null;
|
||||||
|
else if (p === "useDefaultAlerts") out[p] = event.useDefaultAlerts === true;
|
||||||
|
else if (synthetic && NULL_ON_OCCURRENCE.has(p)) out[p] = null;
|
||||||
|
else if (p in event) out[p] = event[p];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- what a single occurrence will not take ---------- */
|
/* ---------- what a single occurrence will not take ---------- */
|
||||||
|
|
||||||
/** Refused outright, with `invalidProperties`. */
|
/** Refused outright, with `invalidProperties`. */
|
||||||
@@ -229,10 +266,13 @@ export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied:
|
|||||||
return { applied };
|
return { applied };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The occurrence a slot currently addresses — which is not a fixed thing. */
|
/**
|
||||||
export function occurrenceAt(base: Obj, slot: number): Occurrence | null {
|
* The occurrence a recurrence id addresses, which no later write moves.
|
||||||
const index = slot - rotation(base);
|
*
|
||||||
if (index < 0) return null;
|
* An id whose date the rule no longer generates — excluded, or past a `count`
|
||||||
|
* — resolves to nothing, and the caller turns that into `notFound`.
|
||||||
|
*/
|
||||||
|
export function occurrenceAt(base: Obj, recurrenceId: string): Occurrence | null {
|
||||||
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
|
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
|
||||||
return all.find((o) => o.index === index) ?? null;
|
return all.find((o) => o.recurrenceId === recurrenceId) ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Real signed messages, for driving signature checking against the mock.
|
||||||
|
*
|
||||||
|
* These are not hand-written. Each was produced by `openssl smime -sign` with a
|
||||||
|
* generated certificate and is stored base64 so no editor, formatter or
|
||||||
|
* checkout setting can touch a byte of it -- a signature is over exact octets,
|
||||||
|
* and a stray line-ending normalisation would turn a working fixture into a
|
||||||
|
* broken one for reasons invisible in a diff.
|
||||||
|
*
|
||||||
|
* The same files back the unit tests, in web/src/lib/smime/__tests__/fixtures.
|
||||||
|
*
|
||||||
|
* good Ada Lovelace <[email protected]>, RSA/SHA-256, intact
|
||||||
|
* tampered the same message with one word of the body changed and the
|
||||||
|
* signature untouched -- what the feature exists to catch
|
||||||
|
* imposter signed with a certificate for [email protected] while claiming
|
||||||
|
* to be from Ada, which is a valid signature by the wrong person
|
||||||
|
*/
|
||||||
|
export const SIGNED_MESSAGES = {
|
||||||
|
good: "VG86IHlvdUBleGFtcGxlLmNvbQpGcm9tOiBBZGEgTG92ZWxhY2UgPGFkYUBleGFtcGxlLmNvbT4KU3ViamVjdDogQSBub3RlCk1JTUUtVmVyc2lvbjogMS4wCkNvbnRlbnQtVHlwZTogbXVsdGlwYXJ0L3NpZ25lZDsgcHJvdG9jb2w9ImFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlIjsgbWljYWxnPSJzaGEtMjU2IjsgYm91bmRhcnk9Ii0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MiIKClRoaXMgaXMgYW4gUy9NSU1FIHNpZ25lZCBtZXNzYWdlCgotLS0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MgpDb250ZW50LVR5cGU6IHRleHQvcGxhaW47IGNoYXJzZXQ9dXRmLTgNCg0KVGhlIEFuYWx5dGljYWwgRW5naW5lIGhhcyBubyBwcmV0ZW5zaW9ucyB3aGF0ZXZlciB0byBvcmlnaW5hdGUgYW55dGhpbmcuDQoKLS0tLS0tMTJEMDBFQkMwQjVEMzM1MkYwRTJGMjVCMUE1RDM1NTIKQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi94LXBrY3M3LXNpZ25hdHVyZTsgbmFtZT0ic21pbWUucDdzIgpDb250ZW50LVRyYW5zZmVyLUVuY29kaW5nOiBiYXNlNjQKQ29udGVudC1EaXNwb3NpdGlvbjogYXR0YWNobWVudDsgZmlsZW5hbWU9InNtaW1lLnA3cyIKCk1JSUdKd1lKS29aSWh2Y05BUWNDb0lJR0dEQ0NCaFFDQVFFeER6QU5CZ2xnaGtnQlpRTUVBZ0VGQURBTEJna3EKaGtpRzl3MEJCd0dnZ2dPTk1JSURpVENDQW5HZ0F3SUJBZ0lVUGc0OW12c1VhQ0ZvSUdYV1ZyRTlyNWFGVm1NdwpEUVlKS29aSWh2Y05BUUVMQlFBd05ERVZNQk1HQTFVRUF3d01RV1JoSUV4dmRtVnNZV05sTVJzd0dRWURWUVFLCkRCSkJibUZzZVhScFkyRnNJRVZ1WjJsdVpYTXdIaGNOTWpZd09UQTFNRGd5TnpRNFdoY05Nell3T1RBeU1EZ3kKTnpRNFdqQTBNUlV3RXdZRFZRUUREQXhCWkdFZ1RHOTJaV3hoWTJVeEd6QVpCZ05WQkFvTUVrRnVZV3g1ZEdsagpZV3dnUlc1bmFXNWxjekNDQVNJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dFUEFEQ0NBUW9DZ2dFQkFKU0JGYnJCCmtTTFRySG91Zlc1V05Zb0hmUFFZZCtrZWVTc1puaGw4TkdjVFVpb1hMRlBnWCt1ZW9sZWJNQlJ2U1ErZUZuWFYKY1lnRHR1NHllNXFmeVlMM1d2Q1dRb2l3Z3UyblA4ejZrRlRpUUtsdTJaUkNZc20vMCtEU0QyOHdIUUZ4KzlOcwpsTFlDZGsyMmZsVWhNbmtDa1d2ZFJiMDQ4K0o3NjJCY3h4bkRDRXphK0RQZ3ROcy9rSTJVcWNoaStWUVpaV1F1Ck1mRTU4ZzJVTTJaM3NlNTVRZlMydll0NGo3cFFYanRjVHNqT3hUUlVmenNzbGFoR0xjTklTR2w1a2RqTDV3cngKMUx3dzNZRWwxbnVjUzFRWkR0N3BjU0dOVVFsZE83ZTFyaDBReFFabG5SekZ5a09FSEpSakRvMDdZOWJjdmhuYQp2NWVPUHlPRDBweFdTMWNDQXdFQUFhT0JrakNCanpBZEJnTlZIUTRFRmdRVVFPamRqbHJwVkY0TXBsdDNISmNQCkhFVG9tN1F3SHdZRFZSMGpCQmd3Rm9BVVFPamRqbHJwVkY0TXBsdDNISmNQSEVUb203UXdEd1lEVlIwVEFRSC8KQkFVd0F3RUIvekFhQmdOVkhSRUVFekFSZ1E5aFpHRkFaWGhoYlhCc1pTNWpiMjB3Q3dZRFZSMFBCQVFEQWdlQQpNQk1HQTFVZEpRUU1NQW9HQ0NzR0FRVUZCd01FTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFCSXFHRjRoQmwyClRBTUIxeU9MK3gySiswQVNWYXJyemZ5eVZST2JZK0JaL0dwTG04RGozYkU5a243cVBldjc5dzVqWGlqdkUzOWEKaFpqRG9KWmxsd1ZxbEdNSjZBbWRDR0VkMHcxQStpZnB4SUo2SUs2cTk4SE9vTUVOR0tRZ0RrdTFoUURISVZrLwpsYWVRTEx4Wk12KzlZbHpRTEltR0kyOUl0R2ZFTks2YnZqSzlVaXJyWmNBaGVpSkhCN2ZBOVoyOFRmRkgrTXNPCkpuQlRhbkdrc3d4WUkyZzJKblZiZnNLU3pHVXppUzhQYTVMSTR3UUJqTnZ2OUtMV0tvMk9SbEdlUXltdlRVK2sKL0o5Sk83QngzakphZUpJS0t1K25SVEdjZVFNOE9qandxVzlFQXJYVmZhOTcySWg5bitYditXZitoekVocDZGYQpjT20rNXMweUlVQ0tNWUlDWGpDQ0Fsb0NBUUV3VERBME1SVXdFd1lEVlFRRERBeEJaR0VnVEc5MlpXeGhZMlV4Ckd6QVpCZ05WQkFvTUVrRnVZV3g1ZEdsallXd2dSVzVuYVc1bGN3SVVQZzQ5bXZzVWFDRm9JR1hXVnJFOXI1YUYKVm1Nd0RRWUpZSVpJQVdVREJBSUJCUUNnZ2VRd0dBWUpLb1pJaHZjTkFRa0RNUXNHQ1NxR1NJYjNEUUVIQVRBYwpCZ2txaGtpRzl3MEJDUVV4RHhjTk1qWXdPVEExTURneU56UTRXakF2QmdrcWhraUc5dzBCQ1FReElnUWdENnROCkV1RVc1VWxZdmFuODhqZGJSMEh3RkpuSnhoMFl0SFVHQTlOSXlmOHdlUVlKS29aSWh2Y05BUWtQTVd3d2FqQUwKQmdsZ2hrZ0JaUU1FQVNvd0N3WUpZSVpJQVdVREJBRVdNQXNHQ1dDR1NBRmxBd1FCQWpBS0JnZ3Foa2lHOXcwRApCekFPQmdncWhraUc5dzBEQWdJQ0FJQXdEUVlJS29aSWh2Y05Bd0lDQVVBd0J3WUZLdzREQWdjd0RRWUlLb1pJCmh2Y05Bd0lDQVNnd0RRWUpLb1pJaHZjTkFRRUJCUUFFZ2dFQWppV1VvSmtGbUN4ZGN3cFNRVFdqUmlseTY4NU0KNEpRNTgzMlZSbFdBM0toQ2tuMC9yc3ptR0NzQ1R0MERBQkVWWU1XMU42ck4wbjBpTEt5ZkNlVVNkL1BQVUFWLwp2UEI3b20veWhCWnBTS1NDWWtBajVMOHFzc3M4cEZRVUczUjVtOFBwcjFkN0Vvcm5ydkVxWnJLc2s3S3grMk81CmxGUExSRUpHWUtnSDVoOHI4NGRIek9Hek9sUjZKVVdqbVFUSEJVQ0dkZUhKdmJOaHp1TFoyQnFvU3VVYzJXcEYKWXNnVGJiSWZQSXdaZFRNZVBtUHIrYzBMYkloRE05S0JhL1J6OWVZRjlOUitEL2ZvRnZVQ2dML0tXcEtnZ2FtUQprVjVndUt2T3FzYUtmNC9kYjE4OEl1UkVibkdVd3NjeVR1TlV1OXUrc0toaVlnZDAydFZyS2RZUGhBPT0KCi0tLS0tLTEyRDAwRUJDMEI1RDMzNTJGMEUyRjI1QjFBNUQzNTUyLS0KCg==",
|
||||||
|
tampered: "VG86IHlvdUBleGFtcGxlLmNvbQpGcm9tOiBBZGEgTG92ZWxhY2UgPGFkYUBleGFtcGxlLmNvbT4KU3ViamVjdDogQSBub3RlCk1JTUUtVmVyc2lvbjogMS4wCkNvbnRlbnQtVHlwZTogbXVsdGlwYXJ0L3NpZ25lZDsgcHJvdG9jb2w9ImFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlIjsgbWljYWxnPSJzaGEtMjU2IjsgYm91bmRhcnk9Ii0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MiIKClRoaXMgaXMgYW4gUy9NSU1FIHNpZ25lZCBtZXNzYWdlCgotLS0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MgpDb250ZW50LVR5cGU6IHRleHQvcGxhaW47IGNoYXJzZXQ9dXRmLTgNCg0KVGhlIEFuYWx5dGljYWwgRW5naW5lIGhhcyBubyBwcmV0ZW5zaW9ucyB3aGF0c29ldmVyIHRvIG9yaWdpbmF0ZSBhbnl0aGluZy4NCgotLS0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MgpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlOyBuYW1lPSJzbWltZS5wN3MiCkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IGJhc2U2NApDb250ZW50LURpc3Bvc2l0aW9uOiBhdHRhY2htZW50OyBmaWxlbmFtZT0ic21pbWUucDdzIgoKTUlJR0p3WUpLb1pJaHZjTkFRY0NvSUlHR0RDQ0JoUUNBUUV4RHpBTkJnbGdoa2dCWlFNRUFnRUZBREFMQmdrcQpoa2lHOXcwQkJ3R2dnZ09OTUlJRGlUQ0NBbkdnQXdJQkFnSVVQZzQ5bXZzVWFDRm9JR1hXVnJFOXI1YUZWbU13CkRRWUpLb1pJaHZjTkFRRUxCUUF3TkRFVk1CTUdBMVVFQXd3TVFXUmhJRXh2ZG1Wc1lXTmxNUnN3R1FZRFZRUUsKREJKQmJtRnNlWFJwWTJGc0lFVnVaMmx1WlhNd0hoY05Nall3T1RBMU1EZ3lOelE0V2hjTk16WXdPVEF5TURneQpOelE0V2pBME1SVXdFd1lEVlFRRERBeEJaR0VnVEc5MlpXeGhZMlV4R3pBWkJnTlZCQW9NRWtGdVlXeDVkR2xqCllXd2dSVzVuYVc1bGN6Q0NBU0l3RFFZSktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCQUpTQkZickIKa1NMVHJIb3VmVzVXTllvSGZQUVlkK2tlZVNzWm5obDhOR2NUVWlvWExGUGdYK3Vlb2xlYk1CUnZTUStlRm5YVgpjWWdEdHU0eWU1cWZ5WUwzV3ZDV1FvaXdndTJuUDh6NmtGVGlRS2x1MlpSQ1lzbS8wK0RTRDI4d0hRRngrOU5zCmxMWUNkazIyZmxVaE1ua0NrV3ZkUmIwNDgrSjc2MkJjeHhuRENFemErRFBndE5zL2tJMlVxY2hpK1ZRWlpXUXUKTWZFNThnMlVNMlozc2U1NVFmUzJ2WXQ0ajdwUVhqdGNUc2pPeFRSVWZ6c3NsYWhHTGNOSVNHbDVrZGpMNXdyeAoxTHd3M1lFbDFudWNTMVFaRHQ3cGNTR05VUWxkTzdlMXJoMFF4UVpsblJ6RnlrT0VISlJqRG8wN1k5YmN2aG5hCnY1ZU9QeU9EMHB4V1MxY0NBd0VBQWFPQmtqQ0JqekFkQmdOVkhRNEVGZ1FVUU9qZGpscnBWRjRNcGx0M0hKY1AKSEVUb203UXdId1lEVlIwakJCZ3dGb0FVUU9qZGpscnBWRjRNcGx0M0hKY1BIRVRvbTdRd0R3WURWUjBUQVFILwpCQVV3QXdFQi96QWFCZ05WSFJFRUV6QVJnUTloWkdGQVpYaGhiWEJzWlM1amIyMHdDd1lEVlIwUEJBUURBZ2VBCk1CTUdBMVVkSlFRTU1Bb0dDQ3NHQVFVRkJ3TUVNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUJJcUdGNGhCbDIKVEFNQjF5T0wreDJKKzBBU1ZhcnJ6Znl5VlJPYlkrQlovR3BMbThEajNiRTlrbjdxUGV2Nzl3NWpYaWp2RTM5YQpoWmpEb0pabGx3VnFsR01KNkFtZENHRWQwdzFBK2lmcHhJSjZJSzZxOThIT29NRU5HS1FnRGt1MWhRREhJVmsvCmxhZVFMTHhaTXYrOVlselFMSW1HSTI5SXRHZkVOSzZidmpLOVVpcnJaY0FoZWlKSEI3ZkE5WjI4VGZGSCtNc08KSm5CVGFuR2tzd3hZSTJnMkpuVmJmc0tTekdVemlTOFBhNUxJNHdRQmpOdnY5S0xXS28yT1JsR2VReW12VFUrawovSjlKTzdCeDNqSmFlSklLS3UrblJUR2NlUU04T2pqd3FXOUVBclhWZmE5NzJJaDluK1h2K1dmK2h6RWhwNkZhCmNPbSs1czB5SVVDS01ZSUNYakNDQWxvQ0FRRXdUREEwTVJVd0V3WURWUVFEREF4QlpHRWdURzkyWld4aFkyVXgKR3pBWkJnTlZCQW9NRWtGdVlXeDVkR2xqWVd3Z1JXNW5hVzVsY3dJVVBnNDltdnNVYUNGb0lHWFdWckU5cjVhRgpWbU13RFFZSllJWklBV1VEQkFJQkJRQ2dnZVF3R0FZSktvWklodmNOQVFrRE1Rc0dDU3FHU0liM0RRRUhBVEFjCkJna3Foa2lHOXcwQkNRVXhEeGNOTWpZd09UQTFNRGd5TnpRNFdqQXZCZ2txaGtpRzl3MEJDUVF4SWdRZ0Q2dE4KRXVFVzVVbFl2YW44OGpkYlIwSHdGSm5KeGgwWXRIVUdBOU5JeWY4d2VRWUpLb1pJaHZjTkFRa1BNV3d3YWpBTApCZ2xnaGtnQlpRTUVBU293Q3dZSllJWklBV1VEQkFFV01Bc0dDV0NHU0FGbEF3UUJBakFLQmdncWhraUc5dzBECkJ6QU9CZ2dxaGtpRzl3MERBZ0lDQUlBd0RRWUlLb1pJaHZjTkF3SUNBVUF3QndZRkt3NERBZ2N3RFFZSUtvWkkKaHZjTkF3SUNBU2d3RFFZSktvWklodmNOQVFFQkJRQUVnZ0VBamlXVW9Ka0ZtQ3hkY3dwU1FUV2pSaWx5Njg1TQo0SlE1ODMyVlJsV0EzS2hDa24wL3Jzem1HQ3NDVHQwREFCRVZZTVcxTjZyTjBuMGlMS3lmQ2VVU2QvUFBVQVYvCnZQQjdvbS95aEJacFNLU0NZa0FqNUw4cXNzczhwRlFVRzNSNW04UHByMWQ3RW9ybnJ2RXFacktzazdLeCsyTzUKbEZQTFJFSkdZS2dINWg4cjg0ZEh6T0d6T2xSNkpVV2ptUVRIQlVDR2RlSEp2Yk5oenVMWjJCcW9TdVVjMldwRgpZc2dUYmJJZlBJd1pkVE1lUG1QcitjMExiSWhETTlLQmEvUno5ZVlGOU5SK0QvZm9GdlVDZ0wvS1dwS2dnYW1RCmtWNWd1S3ZPcXNhS2Y0L2RiMTg4SXVSRWJuR1V3c2N5VHVOVXU5dStzS2hpWWdkMDJ0VnJLZFlQaEE9PQoKLS0tLS0tMTJEMDBFQkMwQjVEMzM1MkYwRTJGMjVCMUE1RDM1NTItLQoK",
|
||||||
|
imposter: "VG86IHlvdUBleGFtcGxlLmNvbQpGcm9tOiBBZGEgTG92ZWxhY2UgPGFkYUBleGFtcGxlLmNvbT4KU3ViamVjdDogTm90IHJlYWxseSBBZGEKTUlNRS1WZXJzaW9uOiAxLjAKQ29udGVudC1UeXBlOiBtdWx0aXBhcnQvc2lnbmVkOyBwcm90b2NvbD0iYXBwbGljYXRpb24veC1wa2NzNy1zaWduYXR1cmUiOyBtaWNhbGc9InNoYS0yNTYiOyBib3VuZGFyeT0iLS0tLUEzNzZENzYzQzdGNDc1MDk3MUY3QzA0QjI3QTM4Q0E3IgoKVGhpcyBpcyBhbiBTL01JTUUgc2lnbmVkIG1lc3NhZ2UKCi0tLS0tLUEzNzZENzYzQzdGNDc1MDk3MUY3QzA0QjI3QTM4Q0E3CkNvbnRlbnQtVHlwZTogdGV4dC9wbGFpbjsgY2hhcnNldD11dGYtOA0KDQpUaGUgQW5hbHl0aWNhbCBFbmdpbmUgaGFzIG5vIHByZXRlbnNpb25zIHdoYXRldmVyIHRvIG9yaWdpbmF0ZSBhbnl0aGluZy4NCgotLS0tLS1BMzc2RDc2M0M3RjQ3NTA5NzFGN0MwNEIyN0EzOENBNwpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlOyBuYW1lPSJzbWltZS5wN3MiCkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IGJhc2U2NApDb250ZW50LURpc3Bvc2l0aW9uOiBhdHRhY2htZW50OyBmaWxlbmFtZT0ic21pbWUucDdzIgoKTUlJRnlnWUpLb1pJaHZjTkFRY0NvSUlGdXpDQ0JiY0NBUUV4RHpBTkJnbGdoa2dCWlFNRUFnRUZBREFMQmdrcQpoa2lHOXcwQkJ3R2dnZ05NTUlJRFNEQ0NBakNnQXdJQkFnSVVGZEtOZmhPdkJDaloxMUk3UGpYM1NtNlBTaFF3CkRRWUpLb1pJaHZjTkFRRUxCUUF3R0RFV01CUUdBMVVFQXd3TlUyOXRaV0p2WkhrZ1JXeHpaVEFlRncweU5qQTUKTURVd09ESTNORGhhRncwek5qQTVNREl3T0RJM05EaGFNQmd4RmpBVUJnTlZCQU1NRFZOdmJXVmliMlI1SUVWcwpjMlV3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRQ2xkZ3RlR0lwSTdiemlpazJQCmxvc3JkWVdKS1pTZy9FSjQ0YW05QmFiemUrTkNKVHhNdkUvZnpZRFVuVWdVeUw3WEtVNmRhaGtPQlJyS0VqTEgKRW5SblRjcjhrNlpxc2tGSnd3V2FTQUhqdklUZ0hPUTd3R01Jd2NKbGROdy9ZKzRaUUhlSFVuY1RiYUc4YnlONwpkVnRsNE1HNFBvZFdaTVlYRlVLSDJiRW1QUW5yVG1LZm9oL2l4T2xWbk54dTQrUy9HOXFIK0VJOHNaeXJCeUlXClBZNkNoV1hEbzNDNTdpZkpDdHNxdlNEaUhZeEVOOWROL0RIZ2xLMzhielFPdzRRNzRYVG93aUw0NytwbVUwYkYKRFNnMjdxMmUvaC9ESEFIczE4Vy9YRGNEa3hwRU9IL0IwZS9HdEFLL1I1cUFnZm1uVnZJM2Y3d1JpZlc0bWovUAplRXFmQWdNQkFBR2pnWWt3Z1lZd0hRWURWUjBPQkJZRUZMWWYyd2dLVXBsTE8rYlNYNVZDc3B5d1NPSkpNQjhHCkExVWRJd1FZTUJhQUZMWWYyd2dLVXBsTE8rYlNYNVZDc3B5d1NPSkpNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHcKSGdZRFZSMFJCQmN3RllFVGJXRnNiRzl5ZVVCbGVHRnRjR3hsTG01bGREQVRCZ05WSFNVRUREQUtCZ2dyQmdFRgpCUWNEQkRBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQVFFQUxrUkxrdXNmdHFyUkZOa2hiWmZiT3d0NEtPdGZGa1FuClluNEp6T1lOZnVlU2lkcldLWTNGMnMzWGZCaWNoQmVQVjZ0MXRvT2owK2VhZ1lOK3hpSTlLZnR5eWtWVlliNS8KZFBabEhMUmdSRmF2eGxxTExnMjViQlVFenB3M0xwYU1NYTYyWmhjMUNwME44aUFUVms5dnBNeE4vREZOMnc2SApxZSswQ29RWVJNOGFXL0QzYW9zK0VZS2JOc0IxWlYwQVp6dC9NSlllSnZSaHA3b0gyUUE0c1hwODJmMEYwUkFWCnVTWkNYMzhXemJwZnZsRE9vYXNVVWxPZFBFdnhCQmFRSXB0S0cxcTJER2pxTFpVaUh5eW9udGRqelI2K1ZhaVYKeXBCOGdzNy9vRlNLTm9RRVc4d3pvRW51YlhoNzBBMzZQcXIxVkZGWnRMa05KRFVmYkJZelBqR0NBa0l3Z2dJKwpBZ0VCTURBd0dERVdNQlFHQTFVRUF3d05VMjl0WldKdlpIa2dSV3h6WlFJVUZkS05maE92QkNqWjExSTdQalgzClNtNlBTaFF3RFFZSllJWklBV1VEQkFJQkJRQ2dnZVF3R0FZSktvWklodmNOQVFrRE1Rc0dDU3FHU0liM0RRRUgKQVRBY0Jna3Foa2lHOXcwQkNRVXhEeGNOTWpZd09UQTFNRGd5TnpRNFdqQXZCZ2txaGtpRzl3MEJDUVF4SWdRZwpENnRORXVFVzVVbFl2YW44OGpkYlIwSHdGSm5KeGgwWXRIVUdBOU5JeWY4d2VRWUpLb1pJaHZjTkFRa1BNV3d3CmFqQUxCZ2xnaGtnQlpRTUVBU293Q3dZSllJWklBV1VEQkFFV01Bc0dDV0NHU0FGbEF3UUJBakFLQmdncWhraUcKOXcwREJ6QU9CZ2dxaGtpRzl3MERBZ0lDQUlBd0RRWUlLb1pJaHZjTkF3SUNBVUF3QndZRkt3NERBZ2N3RFFZSQpLb1pJaHZjTkF3SUNBU2d3RFFZSktvWklodmNOQVFFQkJRQUVnZ0VBSEtKbXQ0SmYrZ1kvTmtIS0xueTc3VC9KCkQxc3lBM2xGWjAwOGlUR3htQU5mQVV3VlFXeTdmSEd6UG1mMkZCdjN5ais3bGJTQUo0YjBKSVRDbFowMUVlalcKUUdkaE1ybVZBZ2QwTTU1ckNFZGNMcms3aFBzWlE3VU9kamMyTzIyY1MyWkJ3WkdrUzRyZThhMHF5NUQydEhBaAowZm5tdTB6RG9Wd1p1bzc4UGFuYzk2dGgzU2pTcExsSlU4andNeWl3bFJIWHpQMG5ITzhDUFdTRE1Lemk1KzhICkVIRHZjaml2ekdudFZPSHZhZmVva0UyTm1ySXZKcENFdk1FTVZ5Vm15c2dVRU5UUUpZT0loRWJYdTJrdEs3OWYKangrdzZpWVVaam5wRUhKNzlPTEFyWnNWNitlN3g5bnE2bGhBM3pTdDJDd3BPR3ZmUk0xN3ROMGMrT0FMcGc9PQoKLS0tLS0tQTM3NkQ3NjNDN0Y0NzUwOTcxRjdDMDRCMjdBMzhDQTctLQoK",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The message as bytes, ready to be served as a blob. */
|
||||||
|
export function signedMessage(which: keyof typeof SIGNED_MESSAGES): Buffer {
|
||||||
|
return Buffer.from(SIGNED_MESSAGES[which], "base64");
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { interpretServerAccount, normalizePermission } from "./upstream.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/api/account` is the only place Stalwart lists what an account may do, and
|
||||||
|
* ihasmail used to read the edition out of it and throw the rest away.
|
||||||
|
*/
|
||||||
|
test("the account's permissions are kept alongside the edition", () => {
|
||||||
|
const info = interpretServerAccount({ edition: "enterprise", permissions: ["sysAccountGet", "sysAccountQuery"], locale: "en_US" });
|
||||||
|
assert.deepEqual(info, { edition: "enterprise", permissions: ["sysAccountGet", "sysAccountQuery"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("permission names read the same whichever case the server uses", () => {
|
||||||
|
// The source serialises camelCase; the documentation shows kebab-case.
|
||||||
|
assert.equal(normalizePermission("sys-account-get"), "sysAccountGet");
|
||||||
|
assert.equal(normalizePermission("sysAccountGet"), "sysAccountGet");
|
||||||
|
assert.equal(normalizePermission("sys-dkim-signature-create"), "sysDkimSignatureCreate");
|
||||||
|
assert.deepEqual(interpretServerAccount({ permissions: ["sys-account-get", "sysAccountGet"] }).permissions, ["sysAccountGet"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a body without a usable list yields no permissions rather than failing", () => {
|
||||||
|
assert.deepEqual(interpretServerAccount({ edition: "oss" }), { edition: "oss", permissions: [] });
|
||||||
|
assert.deepEqual(interpretServerAccount({ permissions: "sysAccountGet" }), { edition: null, permissions: [] });
|
||||||
|
assert.deepEqual(interpretServerAccount({ permissions: [1, null, "sysDomainGet"] }).permissions, ["sysDomainGet"]);
|
||||||
|
assert.deepEqual(interpretServerAccount(null), { edition: null, permissions: [] });
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||||
|
process.env.PUSH_URL = "https://ihasmail.example";
|
||||||
|
const push = await import("./push.js");
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// whole file rather than per test; the per-test stubs below layer on top.
|
||||||
|
const NO_NETWORK = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async () => new Response("{}", { status: 599 })) as typeof fetch;
|
||||||
|
process.on("exit", () => { globalThis.fetch = NO_NETWORK; });
|
||||||
|
|
||||||
|
/** A stand-in for Node's ServerResponse: records writes, can be closed. */
|
||||||
|
function fakeOut() {
|
||||||
|
const e = new EventEmitter() as EventEmitter & { destroyed: boolean; written: string[]; write(s: string): boolean };
|
||||||
|
e.destroyed = false; e.written = [];
|
||||||
|
e.write = (s: string) => { e.written.push(s); return true; };
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer any upstream call as Stalwart would for a successful PushSubscription/set. */
|
||||||
|
function stubUpstream(created = true) {
|
||||||
|
const real = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/.well-known/jmap") || url.includes("/jmap/session")) {
|
||||||
|
return new Response(JSON.stringify({ apiUrl: "http://127.0.0.1:1/jmap/", primaryAccounts: { "urn:ietf:params:jmap:mail": "a" },
|
||||||
|
accounts: { a: {} }, capabilities: {}, eventSourceUrl: "", downloadUrl: "", uploadUrl: "", state: "s" }),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
|
const body = { methodResponses: [["PushSubscription/set", created
|
||||||
|
? { created: { s: { id: "sub1", expires: new Date(Date.now() + 7 * 86_400_000).toISOString() } }, updated: { sub1: null } }
|
||||||
|
: { notCreated: { s: { type: "forbidden" } } }, "0"]] };
|
||||||
|
return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
}) as typeof fetch;
|
||||||
|
return () => { globalThis.fetch = real; };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an unknown token is a 404", async () => {
|
||||||
|
assert.equal(await push.receive("nope", { "@type": "StateChange" }), 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tab opened before verification gets no fan-out, and a subscription is started", async () => {
|
||||||
|
const restore = stubUpstream();
|
||||||
|
try {
|
||||||
|
const out = fakeOut();
|
||||||
|
const entry = push.attach("[email protected]", "a", "Basic x", out as never);
|
||||||
|
assert.equal(entry, null, "not verified yet, so the tab must keep its own relay");
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
const st = push.pushStatus();
|
||||||
|
assert.equal(st.accounts.pending + st.accounts.verified, 1);
|
||||||
|
} finally { restore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verification then fan-out: one POST reaches every open tab for the account", async () => {
|
||||||
|
const restore = stubUpstream();
|
||||||
|
try {
|
||||||
|
// First contact starts the subscription; wait for the stubbed create to land.
|
||||||
|
const first = fakeOut();
|
||||||
|
push.attach("[email protected]", "a", "Basic y", first as never);
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
// 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:
|
||||||
|
// deliver a PushVerification to every pending entry by brute force over the known token space is not
|
||||||
|
// possible, so exercise receive() through the module's own map by re-attaching after verification.
|
||||||
|
const status = push.pushStatus();
|
||||||
|
assert.ok(status.accounts.pending >= 1 || status.accounts.verified >= 1);
|
||||||
|
} finally { restore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a StateChange is written to attached tabs as an SSE frame, and closed tabs are dropped", async () => {
|
||||||
|
// Drive the fan-out directly through an entry made verified by the verification path.
|
||||||
|
const restore = stubUpstream();
|
||||||
|
try {
|
||||||
|
const out1 = fakeOut(), out2 = fakeOut();
|
||||||
|
push.attach("[email protected]", "a", "Basic z", out1 as never);
|
||||||
|
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
|
||||||
|
// subscribe request the stub saw. Simplest faithful route: capture the URL Stalwart would POST to.
|
||||||
|
let token: string | null = null;
|
||||||
|
const real = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const b = typeof init?.body === "string" ? init.body : "";
|
||||||
|
const m = /\/api\/push\/([A-Za-z0-9_-]{20,})/.exec(b);
|
||||||
|
if (m) token = m[1];
|
||||||
|
return real(input, init);
|
||||||
|
}) as typeof fetch;
|
||||||
|
// Force a renewal-style subscribe so the URL passes through the capturing fetch.
|
||||||
|
push.attach("[email protected]", "a", "Basic w", out1 as never);
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
globalThis.fetch = real;
|
||||||
|
assert.ok(token, "the subscribe call carries the push URL with the token");
|
||||||
|
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
|
||||||
|
const entry = push.attach("[email protected]", "a", "Basic w", out1 as never);
|
||||||
|
assert.ok(entry, "verified: the tab is served by fan-out");
|
||||||
|
push.attach("[email protected]", "a", "Basic w", out2 as never);
|
||||||
|
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.equal(out2.written.length, 1);
|
||||||
|
out2.destroyed = true; out2.emit("close");
|
||||||
|
await push.receive(token!, { "@type": "StateChange", changed: { a: { Email: "s2" } } });
|
||||||
|
assert.equal(out1.written.length, 2); assert.equal(out2.written.length, 1, "a closed tab receives nothing more");
|
||||||
|
} finally { restore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a malformed body is a 400, not a crash", async () => {
|
||||||
|
assert.equal(await push.receive("nope", "not an object"), 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tab on the relay is moved to fan-out when its account verifies, and its upstream is dropped", async () => {
|
||||||
|
const restore = stubUpstream();
|
||||||
|
try {
|
||||||
|
let token: string | null = null;
|
||||||
|
const real = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const m = /\/api\/push\/([A-Za-z0-9_-]{20,})/.exec(typeof init?.body === "string" ? init.body : "");
|
||||||
|
if (m) token = m[1];
|
||||||
|
return real(input, init);
|
||||||
|
}) as typeof fetch;
|
||||||
|
push.prepare("[email protected]", "a", "Basic m"); // sign-in starts the subscription
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
globalThis.fetch = real;
|
||||||
|
assert.ok(token);
|
||||||
|
const out = fakeOut(); let dropped = 0;
|
||||||
|
assert.equal(push.attach("[email protected]", "a", "Basic m", out as never), null, "not yet verified: relay");
|
||||||
|
push.attachRelay("[email protected]", out as never, () => { dropped++; });
|
||||||
|
assert.equal(push.pushStatus().tabs.relay >= 1, true);
|
||||||
|
assert.equal(await push.receive(token!, { "@type": "PushVerification", verificationCode: "v" }), 200);
|
||||||
|
assert.equal(dropped, 1, "the relay's upstream request was ended on verification");
|
||||||
|
await push.receive(token!, { "@type": "StateChange", changed: { a: { Email: "s9" } } });
|
||||||
|
assert.match(out.written.at(-1) ?? "", /StateChange/, "the same browser stream now receives fan-out");
|
||||||
|
} finally { restore(); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Push by subscription: hold no upstream connection per tab.
|
||||||
|
*
|
||||||
|
* Today every signed-in tab holds a Server-Sent Events stream to ihasmail,
|
||||||
|
* and ihasmail holds a matching stream to Stalwart behind it. The upstream
|
||||||
|
* one is most of what a tab costs -- measured, 81 KiB of TLS state plus the
|
||||||
|
* request objects -- and it is also the only reason Stalwart's connection
|
||||||
|
* limit applies to ihasmail at all.
|
||||||
|
*
|
||||||
|
* RFC 8620 §7.2 defines the other transport: a PushSubscription, where the
|
||||||
|
* server POSTs StateChange objects to a URL the client registers. Stalwart
|
||||||
|
* implements it. So ihasmail registers one subscription per *account*, and
|
||||||
|
* when Stalwart POSTs a change, fans it out to that account's open tabs over
|
||||||
|
* the browser-facing streams it already holds. Nothing is held upstream.
|
||||||
|
*
|
||||||
|
* Nothing here is taken from any other client's implementation; the shapes
|
||||||
|
* are the RFC's.
|
||||||
|
*
|
||||||
|
* The subscription URL must be https and Stalwart must trust its
|
||||||
|
* certificate -- the RFC requires the scheme and Stalwart enforces it. Where
|
||||||
|
* that is not the case the subscription never verifies, and the account
|
||||||
|
* stays on the per-tab relay it uses today. Both paths coexist; the
|
||||||
|
* transition loses no events, because a tab opened before verification keeps
|
||||||
|
* its own relay for its whole life.
|
||||||
|
*/
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import type { ServerResponse } from "node:http";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import { absoluteUpstream, getUpstreamSession, upstreamFor } from "./upstream.js";
|
||||||
|
|
||||||
|
const USING = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"];
|
||||||
|
const RENEW_BEFORE_MS = 60 * 60_000; // renew an hour before Stalwart expires it
|
||||||
|
const VERIFY_TIMEOUT_MS = 3 * 60_000; // Stalwart's first attempt waits 60 s; allow retries
|
||||||
|
const SWEEP_MS = 30_000;
|
||||||
|
|
||||||
|
interface AccountPush {
|
||||||
|
key: string; // upstream base + username
|
||||||
|
username: string;
|
||||||
|
accountId: string;
|
||||||
|
base: string;
|
||||||
|
token: string; // what Stalwart puts in the URL
|
||||||
|
authorization: string; // one live session's credential, for set/verify/renew
|
||||||
|
subscriptionId: string | null;
|
||||||
|
state: "pending" | "verified" | "failed";
|
||||||
|
since: number;
|
||||||
|
expires: number;
|
||||||
|
tabs: Set<ServerResponse>;
|
||||||
|
/** Tabs still on the per-tab relay, with the hook that ends their upstream request. */
|
||||||
|
relays: Map<ServerResponse, () => void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byKey = new Map<string, AccountPush>();
|
||||||
|
const byToken = new Map<string, AccountPush>();
|
||||||
|
let sweeper: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
export function pushEnabled(): boolean {
|
||||||
|
return config.pushMode === "subscribe" && !!config.pushUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyFor(base: string, username: string) { return `${base} ${username}`; }
|
||||||
|
|
||||||
|
async function jmap(entry: AccountPush, calls: unknown[]) {
|
||||||
|
const upstream = await getUpstreamSession(entry.key, entry.authorization, entry.base);
|
||||||
|
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: entry.authorization, "content-type": "application/json", accept: "application/json" },
|
||||||
|
body: JSON.stringify({ using: USING, methodCalls: calls }),
|
||||||
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`upstream ${res.status}`);
|
||||||
|
return (await res.json()) as { methodResponses: [string, Record<string, unknown>, string][] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribe(entry: AccountPush) {
|
||||||
|
const url = `${config.pushUrl!.replace(/\/$/, "")}${config.basePath}/api/push/${entry.token}`;
|
||||||
|
const r = await jmap(entry, [["PushSubscription/set", {
|
||||||
|
create: { s: { deviceClientId: `ihasmail-${entry.token.slice(0, 8)}`, url,
|
||||||
|
types: ["Email", "Mailbox", "Thread", "Identity", "EmailSubmission", "VacationResponse"] } },
|
||||||
|
}, "0"]]);
|
||||||
|
const created = (r.methodResponses[0]?.[1] as { created?: Record<string, { id: string; expires?: string }> }).created?.s;
|
||||||
|
if (!created) throw new Error("subscription not created");
|
||||||
|
entry.subscriptionId = created.id;
|
||||||
|
entry.expires = created.expires ? Date.parse(created.expires) : Date.now() + 7 * 86_400_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verify(entry: AccountPush, code: string) {
|
||||||
|
await jmap(entry, [["PushSubscription/set", { update: { [entry.subscriptionId!]: { verificationCode: code } } }, "0"]]);
|
||||||
|
entry.state = "verified";
|
||||||
|
// Every tab of this account that has been holding its own upstream stream
|
||||||
|
// can now let go of it: the subscription is live, so Stalwart will POST the
|
||||||
|
// same changes here. The browser-facing stream is untouched. Done in this
|
||||||
|
// order there is no gap -- at worst a change lands twice, which is harmless.
|
||||||
|
let moved = 0;
|
||||||
|
for (const [out, dropUpstream] of entry.relays) {
|
||||||
|
entry.relays.delete(out);
|
||||||
|
if (out.destroyed) continue;
|
||||||
|
dropUpstream(); entry.tabs.add(out); moved++;
|
||||||
|
}
|
||||||
|
console.log(`[ihasmail] push: subscription verified for ${entry.username}` + (moved ? `, ${moved} tab(s) moved off the relay` : ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unsubscribe(entry: AccountPush) {
|
||||||
|
if (entry.subscriptionId) {
|
||||||
|
try { await jmap(entry, [["PushSubscription/set", { destroy: [entry.subscriptionId] }, "0"]]); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
byKey.delete(entry.key); byToken.delete(entry.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start (or refresh) the account's subscription. Called at sign-in, so that
|
||||||
|
* by the time the browser opens its stream the verification is usually
|
||||||
|
* already in flight, and called again by attach() as a safety net.
|
||||||
|
*/
|
||||||
|
export function prepare(username: string, accountId: string, authorization: string): AccountPush | null {
|
||||||
|
if (!pushEnabled()) return null;
|
||||||
|
const base = upstreamFor(username);
|
||||||
|
const key = keyFor(base, username);
|
||||||
|
let entry = byKey.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
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() };
|
||||||
|
byKey.set(key, entry); byToken.set(entry.token, entry);
|
||||||
|
subscribe(entry).catch((err) => {
|
||||||
|
entry!.state = "failed";
|
||||||
|
console.warn(`[ihasmail] push: subscribe failed for ${username}: ${(err as Error).message}; relay in use`);
|
||||||
|
});
|
||||||
|
startSweeper();
|
||||||
|
} else {
|
||||||
|
entry.authorization = authorization; // keep a live credential for renewals
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function attach(username: string, accountId: string, authorization: string, out: ServerResponse): AccountPush | null {
|
||||||
|
const entry = prepare(username, accountId, authorization);
|
||||||
|
if (!entry || entry.state !== "verified") return null;
|
||||||
|
entry.tabs.add(out);
|
||||||
|
out.on("close", () => { entry.tabs.delete(out); });
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tab that had to start on the relay registers here with the hook that
|
||||||
|
* ends its upstream request, so verify() can move it to fan-out later.
|
||||||
|
*/
|
||||||
|
export function attachRelay(username: string, out: ServerResponse, dropUpstream: () => void): void {
|
||||||
|
if (!pushEnabled()) return;
|
||||||
|
const entry = byKey.get(keyFor(upstreamFor(username), username));
|
||||||
|
if (!entry) return;
|
||||||
|
entry.relays.set(out, dropUpstream);
|
||||||
|
out.on("close", () => { entry.relays.delete(out); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stalwart's POST. Returns an HTTP status. */
|
||||||
|
export async function receive(token: string, body: unknown): Promise<number> {
|
||||||
|
const entry = byToken.get(token);
|
||||||
|
if (!entry) return 404;
|
||||||
|
const msg = body as { "@type"?: string; verificationCode?: string; changed?: unknown };
|
||||||
|
if (msg["@type"] === "PushVerification" && typeof msg.verificationCode === "string") {
|
||||||
|
try { await verify(entry, msg.verificationCode); return 200; }
|
||||||
|
catch (err) { console.warn(`[ihasmail] push: verify failed: ${(err as Error).message}`); return 500; }
|
||||||
|
}
|
||||||
|
if (msg["@type"] === "StateChange") {
|
||||||
|
const frame = `event: state\ndata: ${JSON.stringify(msg)}\n\n`;
|
||||||
|
for (const out of entry.tabs) { if (!out.destroyed) out.write(frame); }
|
||||||
|
return 200;
|
||||||
|
}
|
||||||
|
return 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One shared timer for every tab: keep-alives, renewals, and cleanup. */
|
||||||
|
function startSweeper() {
|
||||||
|
if (sweeper) return;
|
||||||
|
sweeper = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const entry of [...byKey.values()]) {
|
||||||
|
for (const out of entry.tabs) { if (out.destroyed) entry.tabs.delete(out); else out.write(": ping\n\n"); }
|
||||||
|
if (entry.state === "pending" && now - entry.since > VERIFY_TIMEOUT_MS) {
|
||||||
|
entry.state = "failed";
|
||||||
|
console.warn(`[ihasmail] push: no verification for ${entry.username} within ${VERIFY_TIMEOUT_MS / 1000}s; relay in use`);
|
||||||
|
}
|
||||||
|
if (entry.state === "verified" && entry.expires - now < RENEW_BEFORE_MS) {
|
||||||
|
entry.state = "pending"; entry.since = now;
|
||||||
|
subscribe(entry).catch(() => { entry.state = "failed"; });
|
||||||
|
}
|
||||||
|
if (entry.tabs.size === 0 && (entry.state === "failed" || now - entry.since > 10 * 60_000)) {
|
||||||
|
void unsubscribe(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (byKey.size === 0 && sweeper) { clearInterval(sweeper); sweeper = null; }
|
||||||
|
}, SWEEP_MS);
|
||||||
|
sweeper.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For /api/health: how many accounts are on each path. */
|
||||||
|
export function pushStatus() {
|
||||||
|
let verified = 0, pending = 0, failed = 0, tabs = 0, relays = 0;
|
||||||
|
for (const e of byKey.values()) { tabs += e.tabs.size; relays += e.relays.size; if (e.state === "verified") verified++; else if (e.state === "pending") pending++; else failed++; }
|
||||||
|
return { mode: pushEnabled() ? "subscribe" : "relay", accounts: { verified, pending, failed }, tabs: { fanout: tabs, relay: relays } };
|
||||||
|
}
|
||||||
+30
-1
@@ -5,6 +5,35 @@ import { Readable } from "node:stream";
|
|||||||
import type { Context, Handler } from "hono";
|
import type { Context, Handler } from "hono";
|
||||||
import { stripBasePath } from "../../scripts/basePath.mjs";
|
import { stripBasePath } from "../../scripts/basePath.mjs";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Files that must not be served from anybody's cache, the way index.html is
|
||||||
|
* not.
|
||||||
|
*
|
||||||
|
* They went out with `max-age=3600` because they are neither hashed assets nor
|
||||||
|
* HTML, and an hour looks harmless. It is not, for two of them, and a CDN in
|
||||||
|
* front makes it worse: on a deploy the origin had the new build while
|
||||||
|
* Cloudflare went on handing out the previous `sw.js` for hours, with
|
||||||
|
* `cf-cache-status: HIT` and an edge TTL of its own that was longer than what
|
||||||
|
* we asked for. Caught on the 2026-09-08 deploy, where the new worker was live
|
||||||
|
* at the origin and the old one was still being installed by every browser
|
||||||
|
* that asked.
|
||||||
|
*
|
||||||
|
* What that costs is specific rather than general. The service worker is the
|
||||||
|
* app's whole update mechanism: a stale one keeps serving the shell it knows
|
||||||
|
* and never learns there is a newer build, so the deploy simply does not
|
||||||
|
* arrive. And a manifest and a worker that disagree is worse than either being
|
||||||
|
* old -- a fresh manifest advertising a share target to the operating system,
|
||||||
|
* answered by a worker that has never heard of one, sends the share to the
|
||||||
|
* server for a 405.
|
||||||
|
*
|
||||||
|
* `no-cache` does not mean "do not store": the browser and the CDN may both
|
||||||
|
* keep it and revalidate, which is a 304 and costs nothing. It means neither
|
||||||
|
* gets to serve it without asking first, which is the whole requirement.
|
||||||
|
*/
|
||||||
|
function isNeverStale(rel: string, ext: string): boolean {
|
||||||
|
return ext === ".webmanifest" || rel === "/sw.js" || rel === "sw.js";
|
||||||
|
}
|
||||||
|
|
||||||
const MIME: Record<string, string> = {
|
const MIME: Record<string, string> = {
|
||||||
".html": "text/html; charset=utf-8",
|
".html": "text/html; charset=utf-8",
|
||||||
".js": "text/javascript; charset=utf-8",
|
".js": "text/javascript; charset=utf-8",
|
||||||
@@ -116,7 +145,7 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
|||||||
c.header("Content-Length", String(st.size));
|
c.header("Content-Length", String(st.size));
|
||||||
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
|
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
|
||||||
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
} else if (ext === ".html") {
|
} else if (ext === ".html" || isNeverStale(rel, ext)) {
|
||||||
c.header("Cache-Control", "no-cache");
|
c.header("Cache-Control", "no-cache");
|
||||||
c.header("Content-Security-Policy", APP_CSP);
|
c.header("Content-Security-Policy", APP_CSP);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* What may be served stale, and what may not.
|
||||||
|
*
|
||||||
|
* This is not a preference about freshness. The service worker is the app's
|
||||||
|
* whole update mechanism: a browser holding an old one goes on being served
|
||||||
|
* the shell that worker knows and never finds out a deploy happened. On
|
||||||
|
* 2026-09-08 the origin had the new build while Cloudflare handed out the
|
||||||
|
* previous `sw.js` for hours, because it was neither a hashed asset nor HTML
|
||||||
|
* and so went out with an hour's max-age that the CDN then extended.
|
||||||
|
*
|
||||||
|
* A static root of our own, since CI runs the tests before the build and
|
||||||
|
* `web/dist` does not exist yet.
|
||||||
|
*/
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "ihasmail-cache-"));
|
||||||
|
mkdirSync(join(root, "assets"));
|
||||||
|
writeFileSync(join(root, "assets", "app-a1b2c3.js"), "console.log(1)\n");
|
||||||
|
writeFileSync(join(root, "sw.js"), "/* worker */\n");
|
||||||
|
writeFileSync(join(root, "manifest.webmanifest"), `{"name":"ihasmail"}`);
|
||||||
|
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
|
||||||
|
writeFileSync(join(root, "img.png"), "not really a png");
|
||||||
|
|
||||||
|
process.env.STATIC_DIR = root;
|
||||||
|
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||||
|
const { createApp } = await import("./app.js");
|
||||||
|
|
||||||
|
const cacheControl = async (path: string) => {
|
||||||
|
const res = await createApp().request(path);
|
||||||
|
assert.equal(res.status, 200, `${path} should be served`);
|
||||||
|
return res.headers.get("cache-control") ?? "";
|
||||||
|
};
|
||||||
|
|
||||||
|
test("the service worker is never served from a cache without asking", async () => {
|
||||||
|
// `no-cache` permits storing it and requires revalidating it, which is a 304
|
||||||
|
// and costs nothing. What it forbids is a browser or a CDN answering with
|
||||||
|
// its own copy, which is the whole failure.
|
||||||
|
assert.match(await cacheControl("/sw.js"), /no-cache/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nor is the manifest, which the worker has to agree with", async () => {
|
||||||
|
// A fresh manifest advertising a share target, answered by a worker that has
|
||||||
|
// never heard of one, sends the share to the server for a 405. Either being
|
||||||
|
// old is survivable; the two disagreeing is not.
|
||||||
|
assert.match(await cacheControl("/manifest.webmanifest"), /no-cache/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the manifest is still served as a manifest", async () => {
|
||||||
|
const res = await createApp().request("/manifest.webmanifest");
|
||||||
|
assert.match(res.headers.get("content-type") ?? "", /application\/manifest\+json/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("index.html was already revalidated, and still is", async () => {
|
||||||
|
assert.match(await cacheControl("/"), /no-cache/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hashed assets are still immutable for a year", async () => {
|
||||||
|
// The name changes when the bytes do, so there is nothing to go stale --
|
||||||
|
// and this is the caching that makes the app load quickly at all.
|
||||||
|
const cc = await cacheControl("/assets/app-a1b2c3.js");
|
||||||
|
assert.match(cc, /immutable/);
|
||||||
|
assert.match(cc, /max-age=31536000/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("everything else keeps its ordinary hour", async () => {
|
||||||
|
// The rule is narrow on purpose: two files, named, rather than a policy that
|
||||||
|
// quietly stops the icons and fonts being cached too.
|
||||||
|
assert.match(await cacheControl("/img.png"), /max-age=3600/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("under a prefix, the worker is still the worker", async () => {
|
||||||
|
// The mount comes off before the path is matched, so this has to hold for a
|
||||||
|
// subpath deployment as well -- where a stale worker is exactly as bad.
|
||||||
|
const res = await createApp("/mail").request("/mail/sw.js");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.match(res.headers.get("cache-control") ?? "", /no-cache/);
|
||||||
|
});
|
||||||
+70
-12
@@ -132,11 +132,21 @@ export interface AccountInfo {
|
|||||||
locale: string | null;
|
locale: string | null;
|
||||||
/** "oss" | "community" | "enterprise", where the server reports it. */
|
/** "oss" | "community" | "enterprise", where the server reports it. */
|
||||||
edition: string | null;
|
edition: string | null;
|
||||||
|
/**
|
||||||
|
* The account's effective permissions, as Stalwart reports them for the
|
||||||
|
* credential in use. Empty when the server would not say.
|
||||||
|
*
|
||||||
|
* Carried to the browser so it can offer only what the account may do --
|
||||||
|
* administration above all. It is never a grant: Stalwart checks every call
|
||||||
|
* it is sent, and a list that is stale or wrong costs a refused request, not
|
||||||
|
* access.
|
||||||
|
*/
|
||||||
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||||
const INFO_CACHE_MS = 30 * 60_000;
|
const INFO_CACHE_MS = 30 * 60_000;
|
||||||
const EMPTY_INFO: AccountInfo = { locale: null, edition: null };
|
const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* glibc modifiers that name a script rather than a dialect or a currency:
|
* glibc modifiers that name a script rather than a dialect or a currency:
|
||||||
@@ -198,7 +208,9 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
|
|||||||
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
||||||
Object.keys(session.accounts ?? {})[0];
|
Object.keys(session.accounts ?? {})[0];
|
||||||
if (!accountId) return EMPTY_INFO;
|
if (!accountId) return EMPTY_INFO;
|
||||||
const res = await fetch(absoluteUpstream(session.apiUrl), {
|
// Against the server that issued this session, not the default: with a
|
||||||
|
// domain mapped elsewhere, the default has never heard of the account.
|
||||||
|
const res = await fetch(absoluteUpstream(session.apiUrl, session.baseUrl), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { authorization, "content-type": "application/json", accept: "application/json" },
|
headers: { authorization, "content-type": "application/json", accept: "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -226,7 +238,7 @@ async function fetchAccountInfo(authorization: string, session: UpstreamSession)
|
|||||||
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
|
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
|
||||||
const settings = responses.find((r) => r[2] === "s");
|
const settings = responses.find((r) => r[2] === "s");
|
||||||
const account = responses.find((r) => r[2] === "a");
|
const account = responses.find((r) => r[2] === "a");
|
||||||
return { locale: localeOf(settings) ?? localeOf(account), edition: null };
|
return { locale: localeOf(settings) ?? localeOf(account), edition: null, permissions: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
|
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
|
||||||
@@ -237,30 +249,51 @@ function localeOf(call: [string, Record<string, unknown>, string] | undefined):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which edition the server is running. Stalwart deliberately does not publish
|
* Permission names in the form the source serialises them.
|
||||||
* its version number to clients, but 0.16 does report its edition here.
|
*
|
||||||
|
* Stalwart 0.16 builds `/api/account`'s list from the same enum as everything
|
||||||
|
* else, which serialises as camelCase (`sysAccountGet`). Its documentation and
|
||||||
|
* OpenAPI example show kebab-case (`sys-account-get`) instead. Until a live
|
||||||
|
* server settles which is true, both are read as the one form, so a check
|
||||||
|
* written against `sysAccountGet` holds either way.
|
||||||
*/
|
*/
|
||||||
async function fetchEdition(authorization: string, base: string): Promise<string | null> {
|
export function normalizePermission(name: string): string {
|
||||||
|
return name.includes("-") ? name.replace(/-([a-z0-9])/g, (_m, c: string) => c.toUpperCase()) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the server says about the signed-in account: its edition and its
|
||||||
|
* effective permissions. Stalwart deliberately does not publish its version
|
||||||
|
* number to clients, but 0.16 reports both of these here.
|
||||||
|
*/
|
||||||
|
async function fetchServerAccount(authorization: string, base: string): Promise<Pick<AccountInfo, "edition" | "permissions">> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/api/account`, {
|
const res = await fetch(`${base}/api/account`, {
|
||||||
headers: { authorization, accept: "application/json" },
|
headers: { authorization, accept: "application/json" },
|
||||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return { edition: null, permissions: [] };
|
||||||
const body = (await res.json()) as { edition?: unknown };
|
return interpretServerAccount(await res.json());
|
||||||
return typeof body.edition === "string" ? body.edition : null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return { edition: null, permissions: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function interpretServerAccount(body: unknown): Pick<AccountInfo, "edition" | "permissions"> {
|
||||||
|
const b = (body ?? {}) as { edition?: unknown; permissions?: unknown };
|
||||||
|
const permissions = Array.isArray(b.permissions)
|
||||||
|
? [...new Set(b.permissions.filter((p): p is string => typeof p === "string").map(normalizePermission))]
|
||||||
|
: [];
|
||||||
|
return { edition: typeof b.edition === "string" ? b.edition : null, permissions };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAccountInfo(sessionId: string, authorization: string, session: UpstreamSession): Promise<AccountInfo> {
|
export async function getAccountInfo(sessionId: string, authorization: string, session: UpstreamSession): Promise<AccountInfo> {
|
||||||
const cached = infoCache.get(sessionId);
|
const cached = infoCache.get(sessionId);
|
||||||
if (cached && Date.now() - cached.fetchedAt < INFO_CACHE_MS) return cached.info;
|
if (cached && Date.now() - cached.fetchedAt < INFO_CACHE_MS) return cached.info;
|
||||||
let info = EMPTY_INFO;
|
let info = EMPTY_INFO;
|
||||||
try {
|
try {
|
||||||
info = await fetchAccountInfo(authorization, session);
|
info = await fetchAccountInfo(authorization, session);
|
||||||
info = { ...info, edition: await fetchEdition(authorization, session.baseUrl) };
|
info = { ...info, ...(await fetchServerAccount(authorization, session.baseUrl)) };
|
||||||
} catch {
|
} catch {
|
||||||
/* all of this is a nicety - never fail the session over it */
|
/* all of this is a nicety - never fail the session over it */
|
||||||
}
|
}
|
||||||
@@ -288,9 +321,34 @@ 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 STALWART_URL. */
|
||||||
|
/**
|
||||||
|
* Resolve a URL Stalwart handed us against the server we were configured to
|
||||||
|
* talk to.
|
||||||
|
*
|
||||||
|
* Stalwart advertises absolute URLs in its session -- apiUrl, eventSourceUrl
|
||||||
|
* 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
|
||||||
|
* stream, out through the public route even when STALWART_URL names a private
|
||||||
|
* 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
|
||||||
|
* whole difference between 1,665 and 3,680 tabs in 256 MiB.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* should have done all along -- the operator named the route on purpose.
|
||||||
|
* STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behaviour for a setup
|
||||||
|
* that genuinely needs to reach Stalwart at a different origin than the one
|
||||||
|
* it was given.
|
||||||
|
*/
|
||||||
export function absoluteUpstream(url: string, base: string = config.stalwartUrl): string {
|
export function absoluteUpstream(url: string, base: string = config.stalwartUrl): string {
|
||||||
try {
|
try {
|
||||||
return new URL(url, base).toString();
|
const resolved = new URL(url, base);
|
||||||
|
if (config.followAdvertisedUrls) return resolved.toString();
|
||||||
|
const pinned = new URL(base);
|
||||||
|
pinned.pathname = resolved.pathname;
|
||||||
|
pinned.search = resolved.search;
|
||||||
|
pinned.hash = "";
|
||||||
|
return pinned.toString();
|
||||||
} catch {
|
} catch {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -13,22 +13,22 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.2",
|
"@tanstack/react-virtual": "^3.13.2",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.4.15",
|
||||||
"lucide-react": "^0.477.0",
|
"lucide-react": "^0.477.0",
|
||||||
"marked": "^18.0.11",
|
"marked": "^18.0.11",
|
||||||
"qrcode-generator": "^2.0.4",
|
"qrcode-generator": "^2.0.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"wouter": "^3.6.0",
|
"wouter": "^3.11.0",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.2.7",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^6.2.0",
|
"vite": "^8.3.0",
|
||||||
"vitest": "^3.0.8"
|
"vitest": "^4.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,42 @@
|
|||||||
"short_name": "ihasmail",
|
"short_name": "ihasmail",
|
||||||
"description": "Fast, friendly JMAP webmail for Stalwart",
|
"description": "Fast, friendly JMAP webmail for Stalwart",
|
||||||
"_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.",
|
||||||
"start_url": "mail",
|
"start_url": "mail",
|
||||||
"scope": "./",
|
"scope": "./",
|
||||||
|
"categories": ["productivity", "utilities"],
|
||||||
|
"_comment_launch": "One window, not one per launch. A `mailto:` link, a manifest shortcut or a notification tapped while ihasmail is already open should arrive in the copy that is running rather than beside it -- two windows on the same inbox disagree about what has been read. `navigate-existing` rather than `focus-existing` because the latter only focuses and leaves the app to handle the target URL through launchQueue, which nothing here consumes: it would swallow the mailto entirely. The navigation goes through the same beforeunload guard as a reload, so an unsent draft still stops it and asks.",
|
||||||
|
"launch_handler": {
|
||||||
|
"client_mode": "navigate-existing"
|
||||||
|
},
|
||||||
|
"_comment_share_target": "Being in the operating system's share sheet, which is the other half of the Share this app now offers. `action` is relative like everything else here, so it follows the mount; it has to sit inside `scope`, and `./` covers it. POST with multipart because a share can carry files, and a POST to a page is not something the app can answer -- the service worker intercepts it, puts the payload where a tab can collect it, and redirects. `accept` names wildcard families AND explicit types and extensions on purpose: a mail client attaches anything, but wildcard support is not in the specification and operating systems differ over which form they match on, so the explicit list is what holds if the families are ignored. Android and Chromium only -- iOS does not implement share targets at all.",
|
||||||
|
"share_target": {
|
||||||
|
"action": "share",
|
||||||
|
"method": "POST",
|
||||||
|
"enctype": "multipart/form-data",
|
||||||
|
"params": {
|
||||||
|
"title": "title",
|
||||||
|
"text": "text",
|
||||||
|
"url": "url",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "files",
|
||||||
|
"accept": [
|
||||||
|
"image/*", "video/*", "audio/*", "text/*",
|
||||||
|
"application/pdf", "application/zip", "application/json",
|
||||||
|
"application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"application/vnd.oasis.opendocument.text", "application/vnd.oasis.opendocument.spreadsheet",
|
||||||
|
"message/rfc822", "text/calendar", "text/vcard",
|
||||||
|
".pdf", ".zip", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
||||||
|
".odt", ".ods", ".csv", ".txt", ".md", ".eml", ".ics", ".vcf",
|
||||||
|
".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".mp4", ".mp3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"protocol_handlers": [
|
"protocol_handlers": [
|
||||||
{
|
{
|
||||||
"protocol": "mailto",
|
"protocol": "mailto",
|
||||||
|
|||||||
+227
-13
@@ -30,8 +30,74 @@ self.addEventListener("activate", (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Where a share from the operating system is left for a tab to collect.
|
||||||
|
*
|
||||||
|
* Absolute and anchored to the mount, for the same reason the verification key
|
||||||
|
* below is: a relative key is resolved against the URL of whoever asks, and the
|
||||||
|
* worker and a tab deep in `/mail/inbox/…` are not at the same place.
|
||||||
|
*
|
||||||
|
* The files go in one entry each and the rest in a JSON index beside them,
|
||||||
|
* because the Cache API stores Responses and a File is already one body.
|
||||||
|
*/
|
||||||
|
const SHARE_KEY = `${BASE}/ihasmail-share`;
|
||||||
|
const SHARE_MAX_FILES = 20;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Take delivery of a share.
|
||||||
|
*
|
||||||
|
* This is a POST that navigates: the operating system submits a form at the
|
||||||
|
* app and expects a page back. Nothing in ihasmail can answer it directly --
|
||||||
|
* the app is a client-side router with no endpoint at that address, and the
|
||||||
|
* server behind it would have to grow one that understood the composer. So the
|
||||||
|
* worker takes the body, puts it where a tab can find it, and redirects to the
|
||||||
|
* app, which then opens a draft holding it.
|
||||||
|
*
|
||||||
|
* The redirect happens whatever went wrong. A share that fails to stash costs
|
||||||
|
* whatever was being shared, which is bad; a share that fails to *respond*
|
||||||
|
* costs that and leaves the reader looking at a browser error page where they
|
||||||
|
* expected their mail, which is worse.
|
||||||
|
*
|
||||||
|
* There is one case this cannot cover, and the server is deliberately not
|
||||||
|
* taught to: an app still installed whose worker has been cleared away. The
|
||||||
|
* POST then reaches the server, which answers 405, and the share is lost
|
||||||
|
* either way -- the payload only ever existed in that request body. A server
|
||||||
|
* route would trade a plain error for a silent nothing, and a share that
|
||||||
|
* vanishes without saying so is the harder of the two to notice.
|
||||||
|
*/
|
||||||
|
async function stashShare(request) {
|
||||||
|
try {
|
||||||
|
const form = await request.formData();
|
||||||
|
const cache = await caches.open(VERSION);
|
||||||
|
const meta = {
|
||||||
|
at: Date.now(),
|
||||||
|
title: String(form.get("title") ?? ""),
|
||||||
|
text: String(form.get("text") ?? ""),
|
||||||
|
url: String(form.get("url") ?? ""),
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
const files = form.getAll("files").filter((f) => f && typeof f === "object" && "name" in f && f.size > 0);
|
||||||
|
for (const [i, f] of files.slice(0, SHARE_MAX_FILES).entries()) {
|
||||||
|
const key = `${SHARE_KEY}/${i}`;
|
||||||
|
await cache.put(key, new Response(f, { headers: { "content-type": f.type || "application/octet-stream" } }));
|
||||||
|
meta.files.push({ key, name: f.name || `file-${i + 1}`, type: f.type || "application/octet-stream" });
|
||||||
|
}
|
||||||
|
await cache.put(SHARE_KEY, new Response(JSON.stringify(meta), { headers: { "content-type": "application/json" } }));
|
||||||
|
} catch {
|
||||||
|
/* nothing to hand on: the app opens on an empty inbox rather than an error */
|
||||||
|
}
|
||||||
|
// Absolute, because `Response.redirect` rejects a bare path outright rather
|
||||||
|
// than resolving it -- so `${BASE}/mail` would throw here and the share
|
||||||
|
// would end at a browser error page instead of the inbox.
|
||||||
|
return Response.redirect(new URL(`${BASE}/mail?share=1`, self.location.origin).href, 303);
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
const req = event.request;
|
const req = event.request;
|
||||||
|
if (req.method === "POST" && new URL(req.url).pathname === `${BASE}/share`) {
|
||||||
|
event.respondWith(stashShare(req));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (req.method !== "GET") return;
|
if (req.method !== "GET") return;
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
if (url.origin !== self.location.origin) return;
|
if (url.origin !== self.location.origin) return;
|
||||||
@@ -64,14 +130,23 @@ self.addEventListener("fetch", (event) => {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Stalwart signs with VAPID and pushes straight to the browser's push service;
|
* Stalwart signs with VAPID and pushes straight to the browser's push service;
|
||||||
* nothing here talks to ihasmail's server. The payload is an EmailPush object
|
* nothing here talks to ihasmail's server on the way in. The payload is an
|
||||||
* (draft-ietf-jmap-emailpush) carrying enough of the message to show a useful
|
* EmailPush object (draft-ietf-jmap-emailpush) carrying enough of the message
|
||||||
* notification without a round-trip — which matters, because when this fires
|
* to show a useful notification without a round-trip, which is what lets a
|
||||||
* there may be no session to make one with.
|
* notification appear immediately rather than after a request.
|
||||||
|
*
|
||||||
|
* This file used to say that a round-trip was impossible here, and it was
|
||||||
|
* wrong: see the note on `jmap()`. What it can do is ask; what it cannot do is
|
||||||
|
* be sure of an answer, since the session may be gone by the time it does. So
|
||||||
|
* the payload still carries the message and the request is only made when
|
||||||
|
* somebody presses something.
|
||||||
*
|
*
|
||||||
* A JMAP subscription also delivers a PushVerification first, and stays silent
|
* A JMAP subscription also delivers a PushVerification first, and stays silent
|
||||||
* until the client echoes its code back. That cannot be done from here (no
|
* until the client echoes its code back. It is stashed for a tab to confirm
|
||||||
* credentials), so it is stashed for a tab to collect and confirm.
|
* rather than answered here — on the same reasoning, and because a
|
||||||
|
* verification that failed silently would leave push looking broken with
|
||||||
|
* nothing to show for it. Answering it directly is now possible and is worth
|
||||||
|
* revisiting.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -87,13 +162,90 @@ self.addEventListener("fetch", (event) => {
|
|||||||
*/
|
*/
|
||||||
const VERIFY_KEY = `${BASE}/ihasmail-push-verification`;
|
const VERIFY_KEY = `${BASE}/ihasmail-push-verification`;
|
||||||
|
|
||||||
function textOf(email) {
|
/*
|
||||||
|
* What a tab wrote down for this worker: the account, which mailbox is the
|
||||||
|
* archive, and the worker's own text in the reader's language. See
|
||||||
|
* `lib/swFacts.ts` for why any of that has to be handed over rather than
|
||||||
|
* worked out here.
|
||||||
|
*
|
||||||
|
* Everything that depends on it is skipped when it is missing, which is the
|
||||||
|
* state between installing this worker and next opening the app. An action
|
||||||
|
* button with no label, or one that files mail into a mailbox guessed by name,
|
||||||
|
* is worse than the notification that was here before.
|
||||||
|
*/
|
||||||
|
const FACTS_KEY = `${BASE}/ihasmail-worker-facts`;
|
||||||
|
|
||||||
|
async function readFacts() {
|
||||||
|
try {
|
||||||
|
const hit = await (await caches.open(VERSION)).match(FACTS_KEY);
|
||||||
|
return hit ? await hit.json() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A JMAP call, made as the reader.
|
||||||
|
*
|
||||||
|
* This worker was written believing it could not do this -- that acting on
|
||||||
|
* mail needed a session it had no way to hold. It does not: ihasmail's session
|
||||||
|
* is an httpOnly cookie against its own origin, and the only other thing the
|
||||||
|
* API asks for is a fixed `x-requested-with` header that is not a secret and
|
||||||
|
* is not held anywhere. A same-origin fetch from here carries the cookie like
|
||||||
|
* any other, so `Email/set` from a notification is an ordinary request.
|
||||||
|
*
|
||||||
|
* What is genuinely not available is anything the *tab* holds in memory, and
|
||||||
|
* the answer is that the API asks for none of it.
|
||||||
|
*
|
||||||
|
* The session can still be gone -- expired, signed out, or a cookie that did
|
||||||
|
* not survive the browser closing -- which arrives as a 401 and is reported
|
||||||
|
* rather than swallowed. A tap that silently does nothing is the failure worth
|
||||||
|
* avoiding here: the reader has already put the phone down.
|
||||||
|
*/
|
||||||
|
async function jmap(methodCalls) {
|
||||||
|
const res = await fetch(`${BASE}/api/jmap`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" },
|
||||||
|
body: JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], methodCalls }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
// A JMAP method can fail inside a 200. Treat that as a failure too, rather
|
||||||
|
// than reporting success because the transport was fine.
|
||||||
|
const first = body?.methodResponses?.[0];
|
||||||
|
if (!first || first[0] === "error") throw new Error(first?.[1]?.type || "error");
|
||||||
|
const notUpdated = first[1]?.notUpdated;
|
||||||
|
if (notUpdated && Object.keys(notUpdated).length) throw new Error("notUpdated");
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textOf(email, strings) {
|
||||||
const from = email?.from?.[0];
|
const from = email?.from?.[0];
|
||||||
const who = from?.name || from?.email || "New message";
|
const who = from?.name || from?.email || strings.newMessage;
|
||||||
const what = email?.subject || "(no subject)";
|
const what = email?.subject || strings.noSubject;
|
||||||
return { title: who, body: what, preview: email?.preview || "" };
|
return { title: who, body: what, preview: email?.preview || "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Two, because that is what a phone shows. `Notification.maxActions` is 2 on
|
||||||
|
* Android Chrome, and anything past it is dropped silently -- so these are the
|
||||||
|
* two worth having rather than the two that happened to come first. Both are
|
||||||
|
* triage: they are what somebody does to a notification they have read the
|
||||||
|
* whole of on the lock screen and does not need to open.
|
||||||
|
*
|
||||||
|
* Reply is deliberately not among them. It cannot be done from here, so it
|
||||||
|
* would have to open the app -- and an action that opens the app is what
|
||||||
|
* tapping the notification already does.
|
||||||
|
*/
|
||||||
|
function actionsFor(facts) {
|
||||||
|
if (!facts) return [];
|
||||||
|
const actions = [];
|
||||||
|
if (facts.archiveId) actions.push({ action: "archive", title: facts.strings.archive });
|
||||||
|
actions.push({ action: "read", title: facts.strings.markRead });
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener("push", (event) => {
|
self.addEventListener("push", (event) => {
|
||||||
let data = null;
|
let data = null;
|
||||||
try {
|
try {
|
||||||
@@ -120,10 +272,24 @@ self.addEventListener("push", (event) => {
|
|||||||
|
|
||||||
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||||
event.waitUntil((async () => {
|
event.waitUntil((async () => {
|
||||||
|
const facts = await readFacts();
|
||||||
|
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
|
||||||
|
/*
|
||||||
|
* Mark the app icon, without claiming a number.
|
||||||
|
*
|
||||||
|
* `setAppBadge()` with no count shows a dot rather than a figure, which is
|
||||||
|
* the only honest thing to show from here: this worker has no session, so
|
||||||
|
* it cannot ask how many messages are unread, and a push carries the new
|
||||||
|
* mail rather than a total. Counting the payload would badge "2" over an
|
||||||
|
* inbox holding forty. The next time a tab opens, `setUnreadBadge` writes
|
||||||
|
* the real count over the dot.
|
||||||
|
*/
|
||||||
|
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
|
||||||
|
|
||||||
if (!emails.length) {
|
if (!emails.length) {
|
||||||
// A StateChange, or a payload too large to carry the message. Say
|
// A StateChange, or a payload too large to carry the message. Say
|
||||||
// something true rather than inventing a sender.
|
// something true rather than inventing a sender.
|
||||||
await self.registration.showNotification("New mail", {
|
await self.registration.showNotification(strings.newMail, {
|
||||||
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -131,21 +297,69 @@ self.addEventListener("push", (event) => {
|
|||||||
// One notification per message, collapsing repeats of the same message by
|
// One notification per message, collapsing repeats of the same message by
|
||||||
// tag so a re-push does not stack.
|
// tag so a re-push does not stack.
|
||||||
for (const email of emails.slice(0, 5)) {
|
for (const email of emails.slice(0, 5)) {
|
||||||
const { title, body, preview } = textOf(email);
|
const { title, body, preview } = textOf(email, strings);
|
||||||
await self.registration.showNotification(title, {
|
await self.registration.showNotification(title, {
|
||||||
body: preview ? `${body}\n${preview}` : body,
|
body: preview ? `${body}\n${preview}` : body,
|
||||||
icon: `${BASE}/img/icon-192.png`,
|
icon: `${BASE}/img/icon-192.png`,
|
||||||
badge: `${BASE}/img/favicon-64.png`,
|
badge: `${BASE}/img/favicon-64.png`,
|
||||||
tag: `ihasmail-${email.id || body}`,
|
tag: `ihasmail-${email.id || body}`,
|
||||||
data: { url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail` },
|
// Only where there is a message to act on: a payload without an id can
|
||||||
|
// be shown but not archived, and a button that cannot work should not
|
||||||
|
// be drawn.
|
||||||
|
actions: email.id ? actionsFor(facts) : [],
|
||||||
|
data: {
|
||||||
|
url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail`,
|
||||||
|
id: email.id || null,
|
||||||
|
title,
|
||||||
|
accountId: facts?.accountId ?? null,
|
||||||
|
archiveId: facts?.archiveId ?? null,
|
||||||
|
failed: strings.failed ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})());
|
})());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Do what the button said, without opening anything.
|
||||||
|
*
|
||||||
|
* The whole point of an action is that the phone goes back in the pocket, so
|
||||||
|
* this must not fall back to opening the app when the call fails -- that is
|
||||||
|
* the same interruption the action existed to avoid. It re-notifies instead,
|
||||||
|
* saying it did not happen, and leaves opening ihasmail to the reader.
|
||||||
|
*
|
||||||
|
* Archiving replaces the mailbox set rather than adding to it, which is what
|
||||||
|
* archiving is: the message leaves the inbox. Marking read is a keyword and
|
||||||
|
* touches nothing else.
|
||||||
|
*/
|
||||||
|
async function runAction(action, data) {
|
||||||
|
const { id, accountId, archiveId } = data;
|
||||||
|
if (!id || !accountId) return;
|
||||||
|
const patch = action === "archive"
|
||||||
|
? { mailboxIds: { [archiveId]: true } }
|
||||||
|
: { "keywords/$seen": true };
|
||||||
|
try {
|
||||||
|
if (action === "archive" && !archiveId) throw new Error("no archive mailbox");
|
||||||
|
await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]]);
|
||||||
|
} catch {
|
||||||
|
await self.registration.showNotification(data.title || "ihasmail", {
|
||||||
|
body: data.failed || "Could not do that — open ihasmail and try again",
|
||||||
|
icon: `${BASE}/img/icon-192.png`,
|
||||||
|
badge: `${BASE}/img/favicon-64.png`,
|
||||||
|
tag: `ihasmail-failed-${id}`,
|
||||||
|
data: { url: data.url },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener("notificationclick", (event) => {
|
self.addEventListener("notificationclick", (event) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
const url = event.notification.data?.url || `${BASE}/mail`;
|
const data = event.notification.data || {};
|
||||||
|
if (event.action === "archive" || event.action === "read") {
|
||||||
|
event.waitUntil(runAction(event.action, data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = data.url || `${BASE}/mail`;
|
||||||
event.waitUntil((async () => {
|
event.waitUntil((async () => {
|
||||||
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
|
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
|
||||||
// Reuse a tab if one is open rather than piling up windows. Same origin is
|
// Reuse a tab if one is open rather than piling up windows. Same origin is
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { AppShell } from "@/views/AppShell";
|
|||||||
import { MailView } from "@/views/mail/MailView";
|
import { MailView } from "@/views/mail/MailView";
|
||||||
import { ComposerDock } from "@/views/compose/ComposerDock";
|
import { ComposerDock } from "@/views/compose/ComposerDock";
|
||||||
import { setUnreadBadge } from "@/lib/notify";
|
import { setUnreadBadge } from "@/lib/notify";
|
||||||
|
import { publishWorkerFacts } from "@/lib/swFacts";
|
||||||
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
||||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||||
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
||||||
@@ -30,6 +31,8 @@ const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m)
|
|||||||
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
||||||
const FilesView = lazy(() => import("@/views/files/FilesView").then((m) => ({ default: m.FilesView })));
|
const FilesView = lazy(() => import("@/views/files/FilesView").then((m) => ({ default: m.FilesView })));
|
||||||
const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m) => ({ default: m.SettingsView })));
|
const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m) => ({ default: m.SettingsView })));
|
||||||
|
// Only ever opened by the few who administer, so nobody else downloads it.
|
||||||
|
const AdminView = lazy(() => import("@/views/admin/AdminView").then((m) => ({ default: m.AdminView })));
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const status = useSession((s) => s.status);
|
const status = useSession((s) => s.status);
|
||||||
@@ -263,6 +266,21 @@ function AuthedApp() {
|
|||||||
});
|
});
|
||||||
}, [inboxUnread, appName]);
|
}, [inboxUnread, appName]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Leave the service worker its briefing.
|
||||||
|
*
|
||||||
|
* Written from here rather than once at startup because everything in it can
|
||||||
|
* change while the app is open -- the language from Settings, the archive
|
||||||
|
* folder from the mailbox list arriving -- and what is written is what the
|
||||||
|
* worker will still be reading a week from now, with no tab to correct it.
|
||||||
|
* See lib/swFacts.ts.
|
||||||
|
*/
|
||||||
|
const archiveId = useMail((s) => s.roleId("archive"));
|
||||||
|
const languageVersion = useLanguageVersion();
|
||||||
|
useEffect(() => {
|
||||||
|
void publishWorkerFacts(accountId, archiveId);
|
||||||
|
}, [accountId, archiveId, languageVersion]);
|
||||||
|
|
||||||
// Request notification permission lazily when enabled
|
// Request notification permission lazily when enabled
|
||||||
const notif = useSettings((s) => s.settings.desktopNotifications);
|
const notif = useSettings((s) => s.settings.desktopNotifications);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -289,6 +307,7 @@ function AuthedApp() {
|
|||||||
<Route path="/calendar/:view?/:date?">{(p) => <CalendarView view={p.view} date={p.date} />}</Route>
|
<Route path="/calendar/:view?/:date?">{(p) => <CalendarView view={p.view} date={p.date} />}</Route>
|
||||||
<Route path="/files/:nodeId?">{(p) => <FilesView nodeId={p.nodeId} />}</Route>
|
<Route path="/files/:nodeId?">{(p) => <FilesView nodeId={p.nodeId} />}</Route>
|
||||||
<Route path="/settings/:section?">{(p) => <SettingsView section={p.section} />}</Route>
|
<Route path="/settings/:section?">{(p) => <SettingsView section={p.section} />}</Route>
|
||||||
|
<Route path="/admin/:section?/:id?">{(p) => <AdminView section={p.section} id={p.id} />}</Route>
|
||||||
<Route path="/login">
|
<Route path="/login">
|
||||||
<Redirect to="/mail" />
|
<Redirect to="/mail" />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export const CAP = {
|
|||||||
websocket: "urn:ietf:params:jmap:websocket",
|
websocket: "urn:ietf:params:jmap:websocket",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** Stalwart's own capability, which carries its `x:` registry methods. */
|
||||||
|
export const STALWART_CAP = "urn:stalwart:jmap";
|
||||||
|
|
||||||
export class JmapMethodError extends Error {
|
export class JmapMethodError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly method: string,
|
public readonly method: string,
|
||||||
@@ -349,6 +352,9 @@ export class JmapClient {
|
|||||||
/** Map method name prefix → required capability URNs. */
|
/** Map method name prefix → required capability URNs. */
|
||||||
function usingFor(method: string): string[] {
|
function usingFor(method: string): string[] {
|
||||||
const type = method.split("/")[0] ?? "";
|
const type = method.split("/")[0] ?? "";
|
||||||
|
// Stalwart's registry: accounts, domains, credentials. Advertised per
|
||||||
|
// account rather than in the session, which supportedUsing() allows for.
|
||||||
|
if (type.startsWith("x:")) return [STALWART_CAP];
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "Mailbox":
|
case "Mailbox":
|
||||||
case "Thread":
|
case "Thread":
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ export interface JmapSession {
|
|||||||
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
|
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
|
||||||
edition?: string | null;
|
edition?: string | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* False when this session may not administer: the operator turned it off,
|
||||||
|
* or the session was signed in without "This is my own device".
|
||||||
|
*/
|
||||||
|
administration?: boolean;
|
||||||
|
/** An administrator on a device not marked as their own; the menu says so. */
|
||||||
|
administrationNeedsOwnDevice?: boolean;
|
||||||
|
/**
|
||||||
|
* The account's effective permissions on that server, as Stalwart reports
|
||||||
|
* them. What the client offers is shaped by these; what is allowed is
|
||||||
|
* decided by Stalwart on every call.
|
||||||
|
*/
|
||||||
|
permissions?: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ADMIN_BASELINE, adminSections, can, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
|
||||||
|
|
||||||
|
const set = (...p: string[]) => permissionSet(p);
|
||||||
|
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
|
||||||
|
const helpdesk = set("sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "jmapEmailGet");
|
||||||
|
const roles = new Map<string, RoleDef>([
|
||||||
|
["user", { id: "user", enabledPermissions: { jmapEmailGet: true } }],
|
||||||
|
["helpdesk", { id: "helpdesk", enabledPermissions: { sysAccountGet: true, sysAccountQuery: true, sysAccountUpdate: true }, roleIds: { user: true } }],
|
||||||
|
["dns", { id: "dns", enabledPermissions: { sysDnsServerUpdate: true }, roleIds: { user: true } }],
|
||||||
|
["loop", { id: "loop", enabledPermissions: {}, roleIds: { loop: true } }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe("who is offered administration", () => {
|
||||||
|
it("needs both halves of reading the account list", () => {
|
||||||
|
expect(hasAdministration(set("sysAccountQuery", "sysAccountGet"))).toBe(true);
|
||||||
|
expect(hasAdministration(set("sysAccountQuery"))).toBe(false);
|
||||||
|
expect(hasAdministration(set("sysAccountGet"))).toBe(false);
|
||||||
|
expect(hasAdministration(permissionSet(undefined))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers each section only with both halves of reading it", () => {
|
||||||
|
expect(adminSections(set("sysDomainQuery", "sysDomainGet"))).toEqual(["domains"]);
|
||||||
|
expect(hasAdministration(set("sysDomainQuery", "sysDomainGet"))).toBe(true);
|
||||||
|
expect(adminSections(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery"))).toEqual(["accounts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads one permission per object and operation", () => {
|
||||||
|
expect(can(helpdesk, "Account", "Update")).toBe(true);
|
||||||
|
expect(can(helpdesk, "Account", "Destroy")).toBe(false);
|
||||||
|
expect(can(helpdesk, "Domain", "Get")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stalwart checks a grant, but not a password change or a delete. Without this,
|
||||||
|
* anyone allowed to edit accounts could take over one that can do more.
|
||||||
|
*/
|
||||||
|
describe("an account that outranks the viewer", () => {
|
||||||
|
it("an ordinary user never does", () => {
|
||||||
|
expect(outranks(helpdesk, { roles: { "@type": "User" } }, null)).toBe(false);
|
||||||
|
expect(outranks(helpdesk, {}, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an administrator does, unless the viewer is one too", () => {
|
||||||
|
expect(outranks(helpdesk, { roles: { "@type": "Admin" } }, roles)).toBe(true);
|
||||||
|
expect(outranks(everything, { roles: { "@type": "Admin" } }, roles)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a custom role does when it carries something the viewer lacks", () => {
|
||||||
|
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, roles)).toBe(false);
|
||||||
|
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } } }, roles)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a role that cannot be read counts against the target, not for it", () => {
|
||||||
|
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, null)).toBe(true);
|
||||||
|
expect(outranks(everything, { roles: { "@type": "Custom", roleIds: { gone: true } } }, roles)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extra permissions on the account itself are counted", () => {
|
||||||
|
expect(outranks(helpdesk, { roles: { "@type": "User" }, permissions: { "@type": "Merge", enabledPermissions: { sysDomainDestroy: true } } }, roles)).toBe(true);
|
||||||
|
// Replace ignores the roles entirely, so only what it lists matters.
|
||||||
|
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } }, permissions: { "@type": "Replace", enabledPermissions: { jmapEmailGet: true } } }, roles)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a role that names itself", () => {
|
||||||
|
expect(resolveRoles(["loop"], roles)).toEqual(new Set());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("granting a role", () => {
|
||||||
|
it("is offered only for roles whose every permission the viewer holds", () => {
|
||||||
|
expect(canGrantRole(helpdesk, "helpdesk", roles)).toBe(true);
|
||||||
|
expect(canGrantRole(helpdesk, "dns", roles)).toBe(false);
|
||||||
|
expect(canGrantRole(everything, "missing", roles)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generated passwords", () => {
|
||||||
|
it("are four groups of five unambiguous characters", () => {
|
||||||
|
const p = generatePassword();
|
||||||
|
expect(p).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
|
||||||
|
expect(p).not.toMatch(/[01lIO]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skip bytes that would favour the start of the alphabet", () => {
|
||||||
|
// 256 % 55 leaves 36 byte values over; a plain modulo would hand those to
|
||||||
|
// the first 36 characters twice as often. Bytes of 220 and up are dropped
|
||||||
|
// and more are drawn, so a batch of nothing but those costs a draw.
|
||||||
|
let call = 0;
|
||||||
|
const source = (n: number) => (call++ === 0 ? new Uint8Array(n).fill(250) : Uint8Array.from({ length: n }, (_, i) => i));
|
||||||
|
expect(generatePassword(source)).toBe("abcde-fghjk-mnpqr-stuvw");
|
||||||
|
expect(call).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { client } from "@/jmap/client";
|
||||||
|
import { aliasList, describeDirectoryError, DirectoryError, hasPassword, passwordPatch, queryAccounts, quotasWithDisk } from "@/lib/adminDirectory";
|
||||||
|
|
||||||
|
describe("setting a password", () => {
|
||||||
|
it("writes into the existing password credential, keeping its place", () => {
|
||||||
|
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "2": { "@type": "Password" as const, secret: "[********]" } } };
|
||||||
|
expect(passwordPatch(account, "new secret")).toEqual({ "credentials/2/secret": "new secret" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds one after the last index when the account has none", () => {
|
||||||
|
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "3": { "@type": "ApiKey" as const } } };
|
||||||
|
expect(passwordPatch(account, "s")).toEqual({ "credentials/4": { "@type": "Password", secret: "s" } });
|
||||||
|
expect(passwordPatch({}, "s")).toEqual({ "credentials/0": { "@type": "Password", secret: "s" } });
|
||||||
|
expect(hasPassword(account)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lists written back", () => {
|
||||||
|
it("re-index aliases the way the server stores a list", () => {
|
||||||
|
expect(aliasList([{ name: "b", domainId: "d1" }, { name: "c", domainId: "d2", enabled: false }])).toEqual({
|
||||||
|
"0": { enabled: true, name: "b", domainId: "d1", description: null },
|
||||||
|
"1": { enabled: false, name: "c", domainId: "d2", description: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("change the disk limit without touching the other quotas", () => {
|
||||||
|
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, 7)).toEqual({ maxEmails: 10, maxDiskQuota: 7 });
|
||||||
|
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, null)).toEqual({ maxEmails: 10 });
|
||||||
|
expect(quotasWithDisk(undefined, 0)).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("explaining a refusal", () => {
|
||||||
|
it("says what a taken address means", () => {
|
||||||
|
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", "exists"))).toMatch(/already in use/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the server's own words for a password policy", () => {
|
||||||
|
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Password must be at least 8 characters long.", ["secret"]))).toContain("at least 8 characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a method-level refusal as well as a set error", () => {
|
||||||
|
expect(describeDirectoryError({ type: "forbidden", message: "x:Account/set: forbidden" })).toMatch(/refused/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the account query", () => {
|
||||||
|
it("filters on @type, the property's name on the object", async () => {
|
||||||
|
// A live 0.16 server answers a plain `type` with "unsupportedFilter - type"
|
||||||
|
// and fails the whole list, which is how this was found.
|
||||||
|
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 0 });
|
||||||
|
await queryAccounts({ type: "User", text: " ada ", position: 50, limit: 50 });
|
||||||
|
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", text: "ada" }, position: 50, limit: 50, calculateTotal: true });
|
||||||
|
call.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stalwart explains a refusal in English, and none of it should reach an
|
||||||
|
* interface in another language as it is. Each case below is a refusal a
|
||||||
|
* live server gave, or one its source says it gives.
|
||||||
|
*/
|
||||||
|
describe("refusals in the reader's language", () => {
|
||||||
|
it("recognises the registry's validators and says it again, without the server's words", () => {
|
||||||
|
// Live, 2026-09-13: a reserved TLD, and a catch-all without a domain.
|
||||||
|
const domain = describeDirectoryError(new DirectoryError("invalidPatch", "Invalid domain name", ["name"]), "domain");
|
||||||
|
expect(domain).toMatch(/isn't a valid domain name/);
|
||||||
|
expect(domain).not.toContain("Invalid domain name");
|
||||||
|
expect(describeDirectoryError(new DirectoryError("invalidPatch", "Invalid email address", ["catchAllAddress"]), "domain")).toMatch(/full address/);
|
||||||
|
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Invalid email local part", ["name"]))).toMatch(/before the @/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never echoes a description it does not know", () => {
|
||||||
|
const text = describeDirectoryError(new DirectoryError("invalidPatch", "Something only the server would say", ["whatever"]));
|
||||||
|
expect(text).not.toContain("Something only the server would say");
|
||||||
|
expect(describeDirectoryError(new DirectoryError("forbidden", "You are not allowed to do that thing"))).not.toContain("not allowed to do that thing");
|
||||||
|
expect(describeDirectoryError(new DirectoryError("someNewType", "Brand new English"))).not.toContain("Brand new English");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells a grant refusal and a directory-backed account apart from a plain no", () => {
|
||||||
|
expect(describeDirectoryError(new DirectoryError("forbidden", "You are not authorized to grant permissions: sysDomainDestroy."))).toMatch(/permissions your own role/);
|
||||||
|
expect(describeDirectoryError(new DirectoryError("forbidden", "Cannot set credentials for accounts in an external directory."))).toMatch(/external directory/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("words a clash and a missing object for what it was about", () => {
|
||||||
|
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", undefined, ["name"]), "domain")).toMatch(/domain name is already in use/);
|
||||||
|
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", undefined))).toMatch(/address is already in use/);
|
||||||
|
expect(describeDirectoryError(new DirectoryError("notFound", undefined), "domain")).toMatch(/domain no longer exists/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains ihasmail's own refusals by their code, not their English message", () => {
|
||||||
|
const own = { status: 403, code: "administration_needs_own_device", message: "Administration is only available when signed in on a device marked as your own (x:Account/query)." };
|
||||||
|
expect(describeDirectoryError(own)).toMatch(/marked as your own/);
|
||||||
|
expect(describeDirectoryError(own)).not.toContain("x:Account/query");
|
||||||
|
expect(describeDirectoryError({ status: 403, code: "administration_disabled", message: "…" })).toMatch(/turned off/);
|
||||||
|
expect(describeDirectoryError({ method: "x:Account/query", type: "unsupportedFilter", message: "x:Account/query: unsupportedFilter - type" })).toBe("The mail server could not carry out the request (unsupportedFilter).");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { describeLinked, dkimAlgorithm, looksLikeDomain, normaliseDomain, parseZoneFile } from "@/lib/adminDomains";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Written the way Stalwart's BIND serialiser writes it (dns-update's
|
||||||
|
* `BindSerializer`): `name IN TYPE value`, and a TXT over 255 bytes as a
|
||||||
|
* parenthesised run of quoted chunks.
|
||||||
|
*/
|
||||||
|
const long = "v=DKIM1; k=rsa; h=sha256; p=" + "A".repeat(400);
|
||||||
|
const zone = [
|
||||||
|
"example.com. IN MX 10 mail.example.com.",
|
||||||
|
'example.com. IN TXT "v=spf1 mx ra=postmaster -all"',
|
||||||
|
"v1-rsa-20260601._domainkey.example.com. IN TXT (",
|
||||||
|
...(long.match(/.{1,255}/g) ?? []).map((c) => ` "${c}"`),
|
||||||
|
")",
|
||||||
|
'_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:\\"postmaster\\"@example.com"',
|
||||||
|
"_jmap._tcp.example.com. IN SRV 0 1 443 mail.example.com.",
|
||||||
|
'example.com. IN CAA 0 issue "letsencrypt.org"',
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("reading the zone file", () => {
|
||||||
|
const records = parseZoneFile(zone);
|
||||||
|
|
||||||
|
it("gives one row per record, without the root dot", () => {
|
||||||
|
expect(records.map((r) => r.type)).toEqual(["MX", "TXT", "TXT", "TXT", "SRV", "CAA"]);
|
||||||
|
expect(records[0]).toMatchObject({ name: "example.com", value: "10 mail.example.com." });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins a split TXT record back into the value a DNS form wants", () => {
|
||||||
|
expect(records[2]!.name).toBe("v1-rsa-20260601._domainkey.example.com");
|
||||||
|
expect(records[2]!.value).toBe(long);
|
||||||
|
expect(records[2]!.line).toContain("(");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unquotes and unescapes TXT values, and leaves other types as written", () => {
|
||||||
|
expect(records[1]!.value).toBe("v=spf1 mx ra=postmaster -all");
|
||||||
|
expect(records[3]!.value).toBe('v=DMARC1; p=reject; rua=mailto:"postmaster"@example.com');
|
||||||
|
expect(records[5]!.value).toBe('0 issue "letsencrypt.org"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a line it cannot read rather than dropping it", () => {
|
||||||
|
expect(parseZoneFile("something unexpected")).toEqual([{ name: "", type: "", value: "something unexpected", line: "something unexpected" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("domain names", () => {
|
||||||
|
it("are written back lower-case without the root dot", () => {
|
||||||
|
expect(normaliseDomain(" Example.COM. ")).toBe("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("are checked loosely before the server decides", () => {
|
||||||
|
expect(looksLikeDomain("mail.example.co.uk")).toBe(true);
|
||||||
|
expect(looksLikeDomain("example")).toBe(false);
|
||||||
|
expect(looksLikeDomain("exa mple.com")).toBe(false);
|
||||||
|
expect(looksLikeDomain("-bad.example.com")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("explaining what still uses a domain", () => {
|
||||||
|
it("counts by kind", () => {
|
||||||
|
expect(describeLinked(["Account", "Account", "DkimSignature", "MailingList", "Whatever"])).toBe("2 accounts, 1 DKIM key, 1 mailing list, 1 other item");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a key's algorithm from its type", () => {
|
||||||
|
expect(dkimAlgorithm("Dkim1Ed25519Sha256")).toBe("Ed25519 · DKIM1");
|
||||||
|
expect(dkimAlgorithm("Dkim2RsaSha256")).toBe("RSA · DKIM2");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* The two sentence builders, which had no tests while they were building
|
||||||
|
* English by concatenation -- and no test would have caught the thing wrong
|
||||||
|
* with them, since the English output was correct. These pin the two
|
||||||
|
* properties that matter now: every fragment goes through the catalogue, and
|
||||||
|
* the joining is Intl's rather than a hardcoded " and ".
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { describeRule as describeSieve } from "../sieve";
|
||||||
|
import { describeRule as describeRecurrence, weekdayOptions } from "../recurrence";
|
||||||
|
import { setUiLanguageForFormatting } from "../datetime";
|
||||||
|
import { setCatalog } from "../i18n";
|
||||||
|
|
||||||
|
describe("sieve describeRule", () => {
|
||||||
|
it("names the header and operator through the catalogue", () => {
|
||||||
|
const s = describeSieve({
|
||||||
|
id: "1", name: "r", join: "allof", enabled: true,
|
||||||
|
tests: [{ type: "header", header: "subject", op: "contains", value: "invoice" }],
|
||||||
|
actions: [{ type: "fileinto", mailbox: "Work" }],
|
||||||
|
} as never);
|
||||||
|
expect(s).toContain("Subject");
|
||||||
|
expect(s).toContain("contains");
|
||||||
|
expect(s).toContain("invoice");
|
||||||
|
expect(s).toContain("Work");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins an allof rule as a conjunction and anyof as a disjunction", () => {
|
||||||
|
const base = {
|
||||||
|
id: "1", name: "r", enabled: true,
|
||||||
|
tests: [
|
||||||
|
{ type: "header", header: "from", op: "is", value: "a@b" },
|
||||||
|
{ type: "header", header: "to", op: "is", value: "c@d" },
|
||||||
|
],
|
||||||
|
actions: [{ type: "keep" }],
|
||||||
|
};
|
||||||
|
expect(describeSieve({ ...base, join: "allof" } as never)).toContain(" and ");
|
||||||
|
expect(describeSieve({ ...base, join: "anyof" } as never)).toContain(" or ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says 'always' when a rule has no tests", () => {
|
||||||
|
const s = describeSieve({ id: "1", name: "r", join: "allof", enabled: true, tests: [], actions: [{ type: "stop" }] } as never);
|
||||||
|
expect(s).toContain("always");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recurrence describeRule", () => {
|
||||||
|
it("describes the simple frequencies", () => {
|
||||||
|
expect(describeRecurrence(undefined)).toBe("Does not repeat");
|
||||||
|
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Daily");
|
||||||
|
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", interval: 3 } as never)).toBe("Every 3 days");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognises Monday to Friday as every weekday", () => {
|
||||||
|
const rule = {
|
||||||
|
"@type": "RecurrenceRule", frequency: "weekly",
|
||||||
|
byDay: ["mo", "tu", "we", "th", "fr"].map((day) => ({ "@type": "NDay", day })),
|
||||||
|
};
|
||||||
|
expect(describeRecurrence(rule as never)).toBe("Every weekday");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a word, not a suffix, for the nth weekday of a month", () => {
|
||||||
|
const s = describeRecurrence({
|
||||||
|
"@type": "RecurrenceRule", frequency: "monthly",
|
||||||
|
byDay: [{ "@type": "NDay", day: "tu", nthOfPeriod: 2 }],
|
||||||
|
} as never);
|
||||||
|
expect(s).toContain("second");
|
||||||
|
expect(s).not.toContain("2nd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps the sentence for count and until rather than appending to it", () => {
|
||||||
|
const s = describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", count: 5 } as never);
|
||||||
|
expect(s).toBe("Daily, 5 times");
|
||||||
|
const u = describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", until: "2026-05-03T00:00:00" } as never);
|
||||||
|
expect(u).toBe("Daily, until 2026-05-03");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes its weekday names from the locale, not a table of English", () => {
|
||||||
|
setUiLanguageForFormatting("de-DE");
|
||||||
|
const names = weekdayOptions().map((w) => w.label);
|
||||||
|
expect(names[0]).toBe("Montag");
|
||||||
|
expect(names).toHaveLength(7);
|
||||||
|
// The narrow forms collide in English ("T" for both Tuesday and Thursday),
|
||||||
|
// which is why they cannot be catalogue keys and come from Intl instead.
|
||||||
|
expect(weekdayOptions().map((w) => w.short)).toHaveLength(7);
|
||||||
|
setUiLanguageForFormatting(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a translated rule through the catalogue", () => {
|
||||||
|
setCatalog("de", { strings: { Daily: "Täglich" }, plurals: {} });
|
||||||
|
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Täglich");
|
||||||
|
setCatalog("en", { strings: {}, plurals: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { htmlDeclaresColors, sanitizeEditorHtml, sanitizeEmailHtml } from "../html";
|
import { EMAIL_BASE_CSS, LIGHT_SURFACE_LUMINANCE, htmlDeclaresColors, markKeptSurfaces, relativeLuminance, sanitizeEditorHtml, sanitizeEmailHtml } from "../html";
|
||||||
|
|
||||||
describe("sanitizeEmailHtml", () => {
|
describe("sanitizeEmailHtml", () => {
|
||||||
it("removes scripts and event handlers", () => {
|
it("removes scripts and event handlers", () => {
|
||||||
@@ -51,6 +51,180 @@ describe("htmlDeclaresColors", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forcing the theme onto mail that styles itself — issue #290.
|
||||||
|
*
|
||||||
|
* The switch above it leaves nearly all HTML mail alone, because one colour
|
||||||
|
* anywhere opts a message out. What this half has to get right is telling a
|
||||||
|
* sheet the design sits on from a surface painted on top of it: neutralise the
|
||||||
|
* first and the white card goes away, keep the second and a button keeps a
|
||||||
|
* label you can still read.
|
||||||
|
*/
|
||||||
|
describe("relativeLuminance", () => {
|
||||||
|
it("reads the forms mail actually uses", () => {
|
||||||
|
expect(relativeLuminance("#ffffff")).toBeCloseTo(1, 5);
|
||||||
|
expect(relativeLuminance("#FFF")).toBeCloseTo(1, 5);
|
||||||
|
expect(relativeLuminance("#000000")).toBeCloseTo(0, 5);
|
||||||
|
expect(relativeLuminance("white")).toBeCloseTo(1, 5);
|
||||||
|
expect(relativeLuminance("rgb(255, 255, 255)")).toBeCloseTo(1, 5);
|
||||||
|
expect(relativeLuminance("rgba(255,255,255,0.5)")).toBeCloseTo(1, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has nothing to say about a colour it cannot read", () => {
|
||||||
|
// Not a failure: the caller treats null as "no deliberate surface", which
|
||||||
|
// is the safe way round — an unreadable colour must not keep a white sheet.
|
||||||
|
expect(relativeLuminance("color-mix(in srgb, red, blue)")).toBeNull();
|
||||||
|
expect(relativeLuminance("var(--brand)")).toBeNull();
|
||||||
|
expect(relativeLuminance("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a fully transparent colour as painting nothing", () => {
|
||||||
|
expect(relativeLuminance("rgba(0,0,0,0)")).toBeNull();
|
||||||
|
expect(relativeLuminance("transparent")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a white wrapper above the threshold and a call to action below it", () => {
|
||||||
|
expect(relativeLuminance("#ffffff")!).toBeGreaterThanOrEqual(LIGHT_SURFACE_LUMINANCE);
|
||||||
|
expect(relativeLuminance("#1155CC")!).toBeLessThan(LIGHT_SURFACE_LUMINANCE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("markKeptSurfaces", () => {
|
||||||
|
const frag = (html: string) => {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.innerHTML = html;
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Marking is only half of it — the other half is the rule in EMAIL_BASE_CSS
|
||||||
|
* that reads the marks, and #310 was a bug in that half rather than in the
|
||||||
|
* marking. So these assert what the reader actually sees: does the
|
||||||
|
* neutraliser hit this element? The selector is lifted out of the stylesheet
|
||||||
|
* rather than copied, so a test cannot quietly drift from the rule it checks.
|
||||||
|
*/
|
||||||
|
const NEUTRALISER = (() => {
|
||||||
|
const m = EMAIL_BASE_CSS.match(
|
||||||
|
/\.ihm-email-root\.forced\s+(\*:not\([^{]*?)\s*\{\s*color: inherit/,
|
||||||
|
);
|
||||||
|
if (!m) throw new Error("could not find the neutraliser rule in EMAIL_BASE_CSS");
|
||||||
|
return m[1]!.trim();
|
||||||
|
})();
|
||||||
|
|
||||||
|
/** True when the theme is forced onto this element rather than leaving it alone. */
|
||||||
|
const neutralised = (el: Element) => el.matches(NEUTRALISER);
|
||||||
|
|
||||||
|
it("keeps a coloured button and drops the white sheet around it", () => {
|
||||||
|
// The shape reported in #290: a Shopify/Klaviyo template whose outer 600px
|
||||||
|
// wrapper carries bgcolor="#ffffff" and whose CTA carries bgcolor="#1155CC".
|
||||||
|
const d = frag('<table bgcolor="#ffffff"><tr><td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td></tr></table>');
|
||||||
|
expect(markKeptSurfaces(d)).toBe(1);
|
||||||
|
expect(d.querySelector("table")!.hasAttribute("data-ihm-keep")).toBe(false);
|
||||||
|
expect(d.querySelector("td")!.hasAttribute("data-ihm-keep")).toBe(true);
|
||||||
|
// The label is not a painted surface itself. It is marked as sitting on
|
||||||
|
// one, which is what stops white-on-blue turning unreadable.
|
||||||
|
expect(d.querySelector("a")!.hasAttribute("data-ihm-keep")).toBe(false);
|
||||||
|
expect(d.querySelector("a")!.hasAttribute("data-ihm-in-keep")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralises a light panel nested inside a dark painted card", () => {
|
||||||
|
// The shape reported in #310: a dark Klaviyo campaign whose 600px cards
|
||||||
|
// are dark enough to be marked, with light content tables inside them.
|
||||||
|
// Those tables used to inherit the card's exemption and render as beige
|
||||||
|
// sheets in an otherwise themed message.
|
||||||
|
const d = frag(
|
||||||
|
'<div style="background-color:#e7e5e2">' +
|
||||||
|
'<div style="background-color:#2b2b2b">' +
|
||||||
|
'<table style="background-color:#e7e5e2"><tr><td>copy</td></tr></table>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>',
|
||||||
|
);
|
||||||
|
expect(markKeptSurfaces(d)).toBe(1);
|
||||||
|
|
||||||
|
const divs = Array.from(d.querySelectorAll("div"));
|
||||||
|
const surround = divs[0]!;
|
||||||
|
const card = divs[1]!;
|
||||||
|
const nested = d.querySelector("table")!;
|
||||||
|
|
||||||
|
// The page surround is a sheet and always was.
|
||||||
|
expect(surround.hasAttribute("data-ihm-keep")).toBe(false);
|
||||||
|
// The card is paint and stays paint.
|
||||||
|
expect(card.hasAttribute("data-ihm-keep")).toBe(true);
|
||||||
|
// The fix, stated the way the reader experiences it: the nested sheet is
|
||||||
|
// themed, and so is the copy inside it. Before #310 both were exempt for
|
||||||
|
// being descendants of the card.
|
||||||
|
expect(neutralised(nested)).toBe(true);
|
||||||
|
expect(neutralised(d.querySelector("td")!)).toBe(true);
|
||||||
|
// The card itself is still left alone, and the page surround still goes.
|
||||||
|
expect(neutralised(card)).toBe(false);
|
||||||
|
expect(neutralised(surround)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still keeps a button that sits inside a nested light panel", () => {
|
||||||
|
// Paint resumes below a sheet, however deep it is: the fix must not cost
|
||||||
|
// a call to action its label just because a sheet came between it and the
|
||||||
|
// card it is on.
|
||||||
|
const d = frag(
|
||||||
|
'<div style="background-color:#2b2b2b">' +
|
||||||
|
'<table style="background-color:#ffffff"><tr>' +
|
||||||
|
'<td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td>' +
|
||||||
|
'</tr></table>' +
|
||||||
|
'</div>',
|
||||||
|
);
|
||||||
|
expect(markKeptSurfaces(d)).toBe(2);
|
||||||
|
expect(neutralised(d.querySelector("table")!)).toBe(true);
|
||||||
|
expect(neutralised(d.querySelector("td")!)).toBe(false);
|
||||||
|
// The label keeps its white, which is the thing #294 bought and this must
|
||||||
|
// not spend.
|
||||||
|
expect(neutralised(d.querySelector("a")!)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves no light panel exempt across the whole reported specimen", () => {
|
||||||
|
// #310 as reported: a dark campaign with no bgcolor attributes, 21 light
|
||||||
|
// panels, 14 of them nested inside dark 600px cards. Those fourteen were
|
||||||
|
// the ones rendering as beige sheets.
|
||||||
|
let cards = "";
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
cards +=
|
||||||
|
'<div style="background-color:#2b2b2b">' +
|
||||||
|
'<table style="background-color:#e7e5e2"><tr><td>copy</td></tr></table>' +
|
||||||
|
'<table style="background-color:#e7e5e2"><tr><td>more</td></tr></table>' +
|
||||||
|
"</div>";
|
||||||
|
}
|
||||||
|
let loose = "";
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
loose += '<table style="background-color:#e7e5e2"><tr><td>loose</td></tr></table>';
|
||||||
|
}
|
||||||
|
const d = frag('<div style="background-color:#e7e5e2">' + cards + loose + "</div>");
|
||||||
|
|
||||||
|
const panels = Array.from(d.querySelectorAll<HTMLElement>("table"));
|
||||||
|
expect(panels.length).toBe(21);
|
||||||
|
|
||||||
|
expect(markKeptSurfaces(d)).toBe(7);
|
||||||
|
expect(panels.filter((p) => !neutralised(p))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads an inline background as well as the attribute", () => {
|
||||||
|
const d = frag('<div style="background-color:#111827">dark</div><div style="background:#f8f8ff">sheet</div>');
|
||||||
|
expect(markKeptSurfaces(d)).toBe(1);
|
||||||
|
expect(d.querySelectorAll("[data-ihm-keep]").length).toBe(1);
|
||||||
|
expect((d.querySelector("[data-ihm-keep]") as HTMLElement).textContent).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks nothing in mail that paints no backgrounds", () => {
|
||||||
|
const d = frag('<p style="color:#333">text</p><a href="https://x.io">link</a>');
|
||||||
|
expect(markKeptSurfaces(d)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the sender's own markup alone, so the switch is reversible", () => {
|
||||||
|
const d = frag('<table><tr><td bgcolor="#1155CC" style="color:#fff">Buy</td></tr></table>');
|
||||||
|
markKeptSurfaces(d);
|
||||||
|
const td = d.querySelector("td")!;
|
||||||
|
expect(td.getAttribute("bgcolor")).toBe("#1155CC");
|
||||||
|
expect(td.style.color).toBe("rgb(255, 255, 255)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A shadow root scopes selectors, not layout. Mail CSS saying `position:fixed`
|
* A shadow root scopes selectors, not layout. Mail CSS saying `position:fixed`
|
||||||
* is still positioned against the viewport, so a sender could paint over the
|
* is still positioned against the viewport, so a sender could paint over the
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { hasHtmlAlternative } from "../html";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The rule: `htmlBody` is derived, so its presence proves nothing. Only the
|
||||||
|
* part's own type says whether there is an HTML alternative to render.
|
||||||
|
*
|
||||||
|
* The shapes below are what Stalwart 0.16.21 actually returned for one thread
|
||||||
|
* on 2026-09-10, read back through `Email/get` with
|
||||||
|
* `bodyProperties: ["partId", "type"]`. A plain-text message named the *same*
|
||||||
|
* part in both lists; a message with a real alternative named two.
|
||||||
|
*
|
||||||
|
* Getting this wrong is not a rendering nicety. Plain text went to the HTML
|
||||||
|
* path, which places the body under `white-space: normal`, so every line break
|
||||||
|
* collapsed: hard-wrapped mail arrived as one paragraph, and the signature and
|
||||||
|
* the quoted reply ran into the prose.
|
||||||
|
*/
|
||||||
|
describe("deciding whether a message has an HTML alternative", () => {
|
||||||
|
it("says no to a plain-text message, whose htmlBody holds the text part", () => {
|
||||||
|
// As returned for [email protected]: htmlBody[0] and textBody[0] are the
|
||||||
|
// same part, typed text/plain.
|
||||||
|
expect(hasHtmlAlternative({ type: "text/plain" }, "Hey,\n\nmy earlier response was before\n")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says yes to a real multipart/alternative", () => {
|
||||||
|
expect(hasHtmlAlternative({ type: "text/html" }, "<p>Hello</p>")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the parameters that follow a media type", () => {
|
||||||
|
// `type` arrives bare in practice, but a charset must not turn a real HTML
|
||||||
|
// part into a plain-text one.
|
||||||
|
expect(hasHtmlAlternative({ type: "text/html; charset=utf-8" }, "<p>Hi</p>")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not fooled by a type that merely starts with the right letters", () => {
|
||||||
|
expect(hasHtmlAlternative({ type: "text/htmlish" }, "<p>Hi</p>")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the type case-insensitively, since a header may be capitalised", () => {
|
||||||
|
expect(hasHtmlAlternative({ type: "TEXT/HTML" }, "<p>Hi</p>")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says no when the part is HTML but its value never arrived", () => {
|
||||||
|
// maxBodyValueBytes can leave a part named with nothing fetched; falling
|
||||||
|
// through to the text body is the useful answer, not an empty pane.
|
||||||
|
expect(hasHtmlAlternative({ type: "text/html" }, undefined)).toBe(false);
|
||||||
|
expect(hasHtmlAlternative({ type: "text/html" }, "")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says no when there is no part at all", () => {
|
||||||
|
expect(hasHtmlAlternative(undefined, undefined)).toBe(false);
|
||||||
|
expect(hasHtmlAlternative({}, "something")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { isTextEntry, keyboard } from "@/lib/keyboard";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Shortcuts after a click on a checkbox (#260).
|
||||||
|
*
|
||||||
|
* The guard that stops "a" archiving while you are typing into the search box
|
||||||
|
* tested `tagName === "INPUT"`, which is also true of a checkbox. A checkbox
|
||||||
|
* keeps focus after a click, so ticking "select all" disabled every shortcut
|
||||||
|
* until the reader clicked somewhere else — and nothing about a checkbox
|
||||||
|
* swallows a keystroke in the first place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const pressFrom = (el: Element, key: string) => {
|
||||||
|
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||||
|
el.dispatchEvent(e);
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pop: (() => void) | null = null;
|
||||||
|
afterEach(() => {
|
||||||
|
pop?.();
|
||||||
|
pop = null;
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isTextEntry", () => {
|
||||||
|
const input = (type?: string) => {
|
||||||
|
const el = document.createElement("input");
|
||||||
|
if (type) el.setAttribute("type", type);
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("is false for the inputs you cannot type into", () => {
|
||||||
|
for (const type of ["checkbox", "radio", "button", "submit", "reset", "file", "color", "range"]) {
|
||||||
|
expect(isTextEntry(input(type)), type).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true for the ones you can", () => {
|
||||||
|
for (const type of ["text", "search", "email", "url", "tel", "password", "number", "date", "time"]) {
|
||||||
|
expect(isTextEntry(input(type)), type).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an input with no type as text, which is what the browser does", () => {
|
||||||
|
expect(isTextEntry(input())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers textarea, select and contenteditable", () => {
|
||||||
|
expect(isTextEntry(document.createElement("textarea"))).toBe(true);
|
||||||
|
// A select takes letters too: typing jumps to the matching option, and a
|
||||||
|
// shortcut would steal that.
|
||||||
|
expect(isTextEntry(document.createElement("select"))).toBe(true);
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.contentEditable = "true";
|
||||||
|
Object.defineProperty(div, "isContentEditable", { value: true });
|
||||||
|
expect(isTextEntry(div)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for a button and for nothing at all", () => {
|
||||||
|
expect(isTextEntry(document.createElement("button"))).toBe(false);
|
||||||
|
expect(isTextEntry(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shortcuts with a checkbox focused", () => {
|
||||||
|
it("still fire — the reported bug", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
pop = keyboard.pushScope("test", [{ keys: "e", description: "Archive", group: "Mail", handler }]);
|
||||||
|
|
||||||
|
const box = document.createElement("input");
|
||||||
|
box.type = "checkbox";
|
||||||
|
document.body.appendChild(box);
|
||||||
|
box.focus();
|
||||||
|
|
||||||
|
pressFrom(box, "e");
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still do not fire from a text field", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
pop = keyboard.pushScope("test", [{ keys: "e", description: "Archive", group: "Mail", handler }]);
|
||||||
|
|
||||||
|
const field = document.createElement("input");
|
||||||
|
field.type = "search";
|
||||||
|
document.body.appendChild(field);
|
||||||
|
field.focus();
|
||||||
|
|
||||||
|
pressFrom(field, "e");
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { parseLdif } from "@/lib/ldif";
|
import { parseLdif, uidFromDn } from "@/lib/ldif";
|
||||||
|
|
||||||
/** The example from issue #174, as SOGo exports it -- lowercased attribute names and all. */
|
/** The example from issue #174, as SOGo exports it -- lowercased attribute names and all. */
|
||||||
const SOGO = `dn: cn=Jane Doe
|
const SOGO = `dn: cn=Jane Doe
|
||||||
@@ -103,3 +103,45 @@ describe("parseLdif", () => {
|
|||||||
expect(r!.attrs.sn).toEqual(["Y"]);
|
expect(r!.attrs.sn).toEqual(["Y"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("an identity for an entry, from its distinguished name", () => {
|
||||||
|
it("gives the same dn the same identity, which is the whole point", () => {
|
||||||
|
expect(uidFromDn("cn=Jane Doe,ou=People")).toBe(uidFromDn("cn=Jane Doe,ou=People"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives two entries two identities", () => {
|
||||||
|
expect(uidFromDn("cn=Jane Doe")).not.toBe(uidFromDn("cn=Alan Turing"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the case and spacing two exports of one directory differ in", () => {
|
||||||
|
// LDAP matches attribute types without regard to case, and exporters lay
|
||||||
|
// a dn out differently. Neither is a different person.
|
||||||
|
const canonical = uidFromDn("cn=Jane Doe,ou=People");
|
||||||
|
expect(uidFromDn("CN=Jane Doe,OU=People")).toBe(canonical);
|
||||||
|
expect(uidFromDn("cn = Jane Doe , ou = People")).toBe(canonical);
|
||||||
|
expect(uidFromDn(" cn=Jane Doe,ou=People ")).toBe(canonical);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not run together words inside a value", () => {
|
||||||
|
expect(uidFromDn("cn=Jane Doe")).not.toBe(uidFromDn("cn=JaneDoe"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so plainly that it came from an LDIF entry", () => {
|
||||||
|
// It becomes the card's uid, where a vCard's own UID also lives. The
|
||||||
|
// namespace is what keeps one from being read as the other.
|
||||||
|
expect(uidFromDn("cn=Jane Doe")).toMatch(/^urn:x-ihasmail:ldif:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a dn a URI would otherwise choke on", () => {
|
||||||
|
const uid = uidFromDn("cn=Ünter Straße \\+ Söhne,ou=Übersicht")!;
|
||||||
|
expect(uid.startsWith("urn:x-ihasmail:ldif:")).toBe(true);
|
||||||
|
expect(uid).not.toMatch(/[\s?#]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has nothing to offer for an entry with no dn", () => {
|
||||||
|
// Such an entry gets an identity of its own instead, and duplicates on
|
||||||
|
// re-import as everything did before there was a dn to match on.
|
||||||
|
expect(uidFromDn("")).toBeNull();
|
||||||
|
expect(uidFromDn(" ")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { rowIsOpen, visibleMessages } from "../openMessage";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Reported from the inbox: with conversation view off, the list showed the two
|
||||||
|
* messages of a thread as separate rows -- correctly -- but clicking either one
|
||||||
|
* highlighted *both* and filled the reading pane with all five messages of the
|
||||||
|
* conversation.
|
||||||
|
*
|
||||||
|
* The setting reached only as far as `collapseThreads` on the query. These are
|
||||||
|
* the two rules that were missing downstream.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const msg = (id: string) => ({ id });
|
||||||
|
|
||||||
|
describe("which row is drawn as open", () => {
|
||||||
|
it("marks only the opened message, not its siblings", () => {
|
||||||
|
// The reported case: two rows, one thread, one of them opened.
|
||||||
|
expect(rowIsOpen("m1", "t1", "m1", "t1")).toBe(true);
|
||||||
|
expect(rowIsOpen("m2", "t1", "m1", "t1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still marks the whole thread when conversation view is on", () => {
|
||||||
|
// No message singled out: every row of the open thread is part of what the
|
||||||
|
// reading pane is showing, so every one of them is open.
|
||||||
|
expect(rowIsOpen("m1", "t1", null, "t1")).toBe(true);
|
||||||
|
expect(rowIsOpen("m2", "t1", null, "t1")).toBe(true);
|
||||||
|
expect(rowIsOpen("m3", "t2", null, "t1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks nothing when nothing is open", () => {
|
||||||
|
expect(rowIsOpen("m1", "t1", null, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark a row whose thread is unknown", () => {
|
||||||
|
// A row whose email has not loaded yet has no thread id; `undefined` must
|
||||||
|
// not match a null openThreadId and light the row up.
|
||||||
|
expect(rowIsOpen("m1", undefined, null, null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("which messages the reading pane shows", () => {
|
||||||
|
const thread = [msg("a"), msg("b"), msg("c")];
|
||||||
|
|
||||||
|
it("shows just the opened message", () => {
|
||||||
|
expect(visibleMessages(thread, "b")).toEqual([msg("b")]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the whole thread when none is singled out", () => {
|
||||||
|
expect(visibleMessages(thread, null)).toEqual(thread);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the thread when the id names nothing in it", () => {
|
||||||
|
/*
|
||||||
|
* Two ways to arrive here: a link shared by somebody whose conversation
|
||||||
|
* view is on, and an `m` parameter left in the URL when the setting is
|
||||||
|
* switched back. A conversation is a better answer to both than an empty
|
||||||
|
* pane, which is what filtering to nothing would produce.
|
||||||
|
*/
|
||||||
|
expect(visibleMessages(thread, "zzz")).toEqual(thread);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an empty thread empty rather than inventing a message", () => {
|
||||||
|
expect(visibleMessages([], "b")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,10 @@ describe("the palettes themselves", () => {
|
|||||||
// The reason there is no "this palette is dark only" machinery: there is
|
// The reason there is no "this palette is dark only" machinery: there is
|
||||||
// no such palette. ihasmail's own gained a light half, and the override,
|
// no such palette. ihasmail's own gained a light half, and the override,
|
||||||
// the toggle's memory and a greyed-out control all went with it.
|
// the toggle's memory and a greyed-out control all went with it.
|
||||||
expect(PALETTES.map((p) => p.id)).toEqual(["default", "ihasmail", "dracula", "gruvbox", "rose-pine", "tokyo-night"]);
|
expect(PALETTES.map((p) => p.id)).toEqual([
|
||||||
|
"default", "ihasmail", "dracula", "gruvbox", "rose-pine", "tokyo-night",
|
||||||
|
"catppuccin", "solarized", "ayu", "kanagawa", "everforest", "primer",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("credits every borrowed palette and neither of ihasmail's own", () => {
|
it("credits every borrowed palette and neither of ihasmail's own", () => {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { canShare, canShareFiles, resetShareSupport, shareFile, shareText } from "@/lib/share";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outcomes are the whole of this module: what the callers do next is
|
||||||
|
* decided entirely by which of the three comes back, and two of the three are
|
||||||
|
* reached through an exception rather than a return.
|
||||||
|
*
|
||||||
|
* `unsupported` is the one worth guarding. It is the instruction to download
|
||||||
|
* instead, and it has to cover the browser that cannot share files *and* the
|
||||||
|
* share that was refused because the tap's activation ran out while the
|
||||||
|
* attachment was fetched -- which arrives as an error indistinguishable from a
|
||||||
|
* permissions refusal, and would otherwise reach the reader as a toast about
|
||||||
|
* something they cannot act on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function stubNavigator(nav: Partial<Navigator>) {
|
||||||
|
vi.stubGlobal("navigator", nav as Navigator);
|
||||||
|
resetShareSupport();
|
||||||
|
}
|
||||||
|
|
||||||
|
const aFile = () => new File(["x"], "note.txt", { type: "text/plain" });
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
resetShareSupport();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("share availability", () => {
|
||||||
|
it("is absent where the browser has no Web Share", () => {
|
||||||
|
stubNavigator({});
|
||||||
|
expect(canShare()).toBe(false);
|
||||||
|
expect(canShareFiles()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks about files separately from sharing at all", () => {
|
||||||
|
// Every iOS and Android browser shares text; not all of them take files,
|
||||||
|
// and a Share button that turns out to be a download is worse than none.
|
||||||
|
stubNavigator({ share: vi.fn(), canShare: () => false });
|
||||||
|
expect(canShare()).toBe(true);
|
||||||
|
expect(canShareFiles()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probes with a real file, since canShare() cannot answer without one", () => {
|
||||||
|
const canShareFn = vi.fn(() => true);
|
||||||
|
stubNavigator({ share: vi.fn(), canShare: canShareFn as unknown as Navigator["canShare"] });
|
||||||
|
expect(canShareFiles()).toBe(true);
|
||||||
|
const probe = (canShareFn.mock.calls[0] as unknown as [ShareData])[0];
|
||||||
|
expect(probe.files?.[0]).toBeInstanceOf(File);
|
||||||
|
expect(probe.files?.[0]?.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks once and remembers, because the answer is about the browser", () => {
|
||||||
|
const canShareFn = vi.fn(() => true);
|
||||||
|
stubNavigator({ share: vi.fn(), canShare: canShareFn as unknown as Navigator["canShare"] });
|
||||||
|
canShareFiles();
|
||||||
|
canShareFiles();
|
||||||
|
canShareFiles();
|
||||||
|
expect(canShareFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sharing text", () => {
|
||||||
|
it("hands the data straight to the sheet", async () => {
|
||||||
|
const share = vi.fn(async () => undefined);
|
||||||
|
stubNavigator({ share });
|
||||||
|
await expect(shareText({ title: "Lunch", text: "One o'clock?" })).resolves.toBe("shared");
|
||||||
|
expect(share).toHaveBeenCalledWith({ title: "Lunch", text: "One o'clock?" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports unsupported rather than throwing where there is no share", async () => {
|
||||||
|
stubNavigator({});
|
||||||
|
await expect(shareText({ text: "hello" })).resolves.toBe("unsupported");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sharing a file", () => {
|
||||||
|
it("passes the file through when the browser takes it", async () => {
|
||||||
|
const share = vi.fn(async () => undefined);
|
||||||
|
stubNavigator({ share, canShare: (() => true) as unknown as Navigator["canShare"] });
|
||||||
|
const f = aFile();
|
||||||
|
await expect(shareFile(f, { title: "note.txt" })).resolves.toBe("shared");
|
||||||
|
expect(share).toHaveBeenCalledWith({ title: "note.txt", files: [f] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call share at all when this file is not shareable", async () => {
|
||||||
|
const share = vi.fn(async () => undefined);
|
||||||
|
stubNavigator({ share, canShare: (() => false) as unknown as Navigator["canShare"] });
|
||||||
|
await expect(shareFile(aFile())).resolves.toBe("unsupported");
|
||||||
|
expect(share).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a closed sheet as a decision, not a failure", async () => {
|
||||||
|
stubNavigator({
|
||||||
|
share: vi.fn(async () => { throw new DOMException("cancelled", "AbortError"); }),
|
||||||
|
canShare: (() => true) as unknown as Navigator["canShare"],
|
||||||
|
});
|
||||||
|
await expect(shareFile(aFile())).resolves.toBe("dismissed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back rather than reporting an error when the gesture has expired", async () => {
|
||||||
|
// What NotAllowedError means here is that fetching the attachment outlived
|
||||||
|
// the tap that asked for it. The caller downloads; the reader sees a file
|
||||||
|
// rather than a message about transient activation.
|
||||||
|
stubNavigator({
|
||||||
|
share: vi.fn(async () => { throw new DOMException("no activation", "NotAllowedError"); }),
|
||||||
|
canShare: (() => true) as unknown as Navigator["canShare"],
|
||||||
|
});
|
||||||
|
await expect(shareFile(aFile())).resolves.toBe("unsupported");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises anything it does not recognise, so a real fault is still reported", async () => {
|
||||||
|
stubNavigator({
|
||||||
|
share: vi.fn(async () => { throw new DOMException("boom", "DataError"); }),
|
||||||
|
canShare: (() => true) as unknown as Navigator["canShare"],
|
||||||
|
});
|
||||||
|
await expect(shareFile(aFile())).rejects.toThrow("boom");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
|
||||||
|
import { SW_CACHE_NAME } from "@/lib/swCache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The handoff, from the tab's side. The worker's half cannot be exercised here
|
||||||
|
* -- sw.js is copied to the build rather than imported, and there is no service
|
||||||
|
* worker under a test runner -- so what is stood up below is the cache it
|
||||||
|
* writes into, keyed and shaped exactly as `stashShare` leaves it.
|
||||||
|
*
|
||||||
|
* That shape is the contract between two files that never see each other, and
|
||||||
|
* it is the thing worth pinning: a drift on either side is silent. Nothing
|
||||||
|
* errors, a share simply arrives at an empty composer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
body: BodyInit;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeCaches(entries: Record<string, Entry>) {
|
||||||
|
const store = new Map(Object.entries(entries));
|
||||||
|
const cache = {
|
||||||
|
match: vi.fn(async (key: string) => {
|
||||||
|
const e = store.get(key);
|
||||||
|
return e ? new Response(e.body, { headers: { "content-type": e.type } }) : undefined;
|
||||||
|
}),
|
||||||
|
delete: vi.fn(async (key: string) => store.delete(key)),
|
||||||
|
put: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
vi.stubGlobal("caches", { open: vi.fn(async (name: string) => (name === SW_CACHE_NAME ? cache : { match: async () => undefined })) });
|
||||||
|
return { cache, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the worker writes, at the keys it writes them under. */
|
||||||
|
function stash(meta: Record<string, unknown>, files: { name: string; type: string; body: string }[] = []) {
|
||||||
|
const entries: Record<string, Entry> = {};
|
||||||
|
const index = files.map((f, i) => ({ key: `/ihasmail-share/${i}`, name: f.name, type: f.type }));
|
||||||
|
entries["/ihasmail-share"] = { body: JSON.stringify({ at: Date.now(), files: index, ...meta }), type: "application/json" };
|
||||||
|
for (const [i, f] of files.entries()) entries[`/ihasmail-share/${i}`] = { body: f.body, type: f.type };
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => vi.unstubAllGlobals());
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
describe("collecting a share", () => {
|
||||||
|
it("finds nothing on an ordinary start, which is almost every start", async () => {
|
||||||
|
fakeCaches({});
|
||||||
|
await expect(collectShare()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a browser with no cache storage at all", async () => {
|
||||||
|
vi.stubGlobal("caches", undefined);
|
||||||
|
await expect(collectShare()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds the files, with their names and types intact", async () => {
|
||||||
|
fakeCaches(stash({ title: "Holiday", text: "", url: "" }, [
|
||||||
|
{ name: "beach.png", type: "image/png", body: "pixels" },
|
||||||
|
{ name: "notes.txt", type: "text/plain", body: "later" },
|
||||||
|
]));
|
||||||
|
const share = await collectShare();
|
||||||
|
expect(share?.title).toBe("Holiday");
|
||||||
|
expect(share?.files.map((f) => [f.name, f.type])).toEqual([["beach.png", "image/png"], ["notes.txt", "text/plain"]]);
|
||||||
|
// The bytes made the trip, not just the index entry describing them.
|
||||||
|
expect(share!.files[0]!.size).toBe("pixels".length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves nothing behind, so it cannot be collected twice", async () => {
|
||||||
|
const { store } = fakeCaches(stash({ text: "hello" }, [{ name: "a.txt", type: "text/plain", body: "x" }]));
|
||||||
|
await collectShare();
|
||||||
|
expect(store.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores one nobody came back for, and still clears it", async () => {
|
||||||
|
// A share to a signed-out ihasmail waits through the sign-in page, so it
|
||||||
|
// cannot expire quickly -- but it must expire, or it opens a composer full
|
||||||
|
// of a forgotten photo on some unrelated morning.
|
||||||
|
const { store } = fakeCaches(stash({ at: Date.now() - SHARE_MAX_AGE_MS - 1000, text: "stale" }));
|
||||||
|
await expect(collectShare()).resolves.toBeNull();
|
||||||
|
expect(store.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an empty share as no share", async () => {
|
||||||
|
fakeCaches(stash({ title: "", text: "", url: "" }));
|
||||||
|
await expect(collectShare()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw on a stash it cannot read", async () => {
|
||||||
|
fakeCaches({ "/ihasmail-share": { body: "not json", type: "application/json" } });
|
||||||
|
await expect(collectShare()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the body a share turns into", () => {
|
||||||
|
it("keeps the link when the text does not already carry it", () => {
|
||||||
|
expect(shareBody({ text: "Look at this", url: "https://example.com/a" })).toBe("Look at this\n\nhttps://example.com/a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not repeat a link the sharing app already put in the text", () => {
|
||||||
|
// Which field a link arrives in is up to whatever shared it, and they do
|
||||||
|
// not agree. Appending unconditionally would double it more often than not.
|
||||||
|
expect(shareBody({ text: "https://example.com/a", url: "https://example.com/a" })).toBe("https://example.com/a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is just the link when that is all there was", () => {
|
||||||
|
expect(shareBody({ text: "", url: "https://example.com/a" })).toBe("https://example.com/a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is just the text when there was no link", () => {
|
||||||
|
expect(shareBody({ text: "a thought", url: "" })).toBe("a thought");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { publishWorkerFacts, FACTS_KEY, type WorkerFacts } from "@/lib/swFacts";
|
||||||
|
import { SW_CACHE_NAME } from "@/lib/swCache";
|
||||||
|
import { setCatalog } from "@/lib/i18n";
|
||||||
|
import { catalog as de } from "@/locales/de";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The briefing is the only thing standing between a notification action and a
|
||||||
|
* button labelled in a language the reader does not use — the worker is plain
|
||||||
|
* JavaScript outside the bundle and cannot reach a catalogue.
|
||||||
|
*
|
||||||
|
* It is also the only place the archive mailbox is named, and getting that
|
||||||
|
* wrong does not fail visibly: a message would be filed somewhere, just not
|
||||||
|
* where Archive means.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fakeCaches() {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
const cache = {
|
||||||
|
put: vi.fn(async (key: string, res: Response) => void store.set(key, await res.text())),
|
||||||
|
match: vi.fn(async (key: string) => (store.has(key) ? new Response(store.get(key)) : undefined)),
|
||||||
|
delete: vi.fn(async () => true),
|
||||||
|
};
|
||||||
|
// Only the worker's own cache: a briefing put anywhere else is one the
|
||||||
|
// worker will never read.
|
||||||
|
const other = { put: vi.fn(), match: vi.fn(), delete: vi.fn() };
|
||||||
|
vi.stubGlobal("caches", { open: vi.fn(async (name: string) => (name === SW_CACHE_NAME ? cache : other)) });
|
||||||
|
return { store, cache };
|
||||||
|
}
|
||||||
|
|
||||||
|
const written = (store: Map<string, string>) => JSON.parse(store.get(FACTS_KEY)!) as WorkerFacts;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
setCatalog("en", { strings: {}, plurals: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the worker's briefing", () => {
|
||||||
|
it("names the account and the archive mailbox", async () => {
|
||||||
|
const { store } = fakeCaches();
|
||||||
|
await publishWorkerFacts("a1", "mb-archive");
|
||||||
|
const facts = written(store);
|
||||||
|
expect(facts.accountId).toBe("a1");
|
||||||
|
expect(facts.archiveId).toBe("mb-archive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the worker's text in the language the tab is in", async () => {
|
||||||
|
// The worker has no catalogue. Everything it will say has to be said here
|
||||||
|
// first, or a German reader gets English buttons on their lock screen.
|
||||||
|
setCatalog("de", de);
|
||||||
|
const { store } = fakeCaches();
|
||||||
|
await publishWorkerFacts("a1", "mb-archive");
|
||||||
|
const facts = written(store);
|
||||||
|
expect(facts.strings.archive).toBe("Archivieren");
|
||||||
|
expect(facts.strings.markRead).toBe("Als gelesen markieren");
|
||||||
|
expect(facts.strings.newMail).toBe("Neue E-Mail");
|
||||||
|
expect(facts.strings.noSubject).toBe("(kein Betreff)");
|
||||||
|
expect(facts.strings.failed).not.toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when there is no archive folder, rather than inventing one", async () => {
|
||||||
|
// The worker draws no Archive button on a null. An account without an
|
||||||
|
// archive is not a reason to file mail somewhere else.
|
||||||
|
const { store } = fakeCaches();
|
||||||
|
await publishWorkerFacts("a1", null);
|
||||||
|
expect(written(store).archiveId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes nothing before there is an account", async () => {
|
||||||
|
const { cache } = fakeCaches();
|
||||||
|
await publishWorkerFacts(null, null);
|
||||||
|
expect(cache.put).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw where the browser has no cache storage", async () => {
|
||||||
|
vi.stubGlobal("caches", undefined);
|
||||||
|
await expect(publishWorkerFacts("a1", "mb-archive")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries every string the worker looks up", async () => {
|
||||||
|
// The worker reads these by name and shows `undefined` for a missing one,
|
||||||
|
// which is the kind of thing that only appears on somebody's lock screen.
|
||||||
|
const { store } = fakeCaches();
|
||||||
|
await publishWorkerFacts("a1", "mb-archive");
|
||||||
|
const facts = written(store);
|
||||||
|
for (const k of ["newMail", "newMessage", "noSubject", "archive", "markRead", "failed"] as const) {
|
||||||
|
expect(facts.strings[k], `missing ${k}`).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,7 @@ function session(caps: Record<string, unknown>): JmapSession {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
client.session = null;
|
client.session = null;
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("the VAPID key", () => {
|
describe("the VAPID key", () => {
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* What the signed-in account may administer, read from the permissions Stalwart
|
||||||
|
* reported for it at sign-in.
|
||||||
|
*
|
||||||
|
* None of this is a security boundary, and nothing here should read as one.
|
||||||
|
* Every administrative call is a JMAP `x:` method sent through the ordinary
|
||||||
|
* proxy, and Stalwart checks each of them against the credential making it --
|
||||||
|
* scoping a tenant administrator's queries to their own tenant, and refusing a
|
||||||
|
* write the account may not make. What this decides is only what the client
|
||||||
|
* *offers*: a menu that appears for the people it can do something for, and
|
||||||
|
* buttons that are there when pressing them would work.
|
||||||
|
*
|
||||||
|
* The one place it is more than presentation is `outranks`, which stands in
|
||||||
|
* for a check Stalwart does not make. See there.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AdminObject = "Account" | "Domain" | "Role" | "MailingList" | "DkimSignature" | "DnsServer" | "Tenant";
|
||||||
|
export type AdminOp = "Get" | "Query" | "Create" | "Update" | "Destroy";
|
||||||
|
|
||||||
|
export type Permissions = ReadonlySet<string>;
|
||||||
|
|
||||||
|
export function permissionSet(list: readonly string[] | null | undefined): Permissions {
|
||||||
|
return new Set(list ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function can(perms: Permissions, object: AdminObject, op: AdminOp): boolean {
|
||||||
|
return perms.has(`sys${object}${op}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminSection = "accounts" | "domains";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sections an account may open, in the order they are listed.
|
||||||
|
*
|
||||||
|
* A list that cannot be read is not worth an entry, so each takes both halves
|
||||||
|
* of reading one: the query that finds the objects and the get that shows them.
|
||||||
|
*/
|
||||||
|
export function adminSections(perms: Permissions): AdminSection[] {
|
||||||
|
const out: AdminSection[] = [];
|
||||||
|
if (can(perms, "Account", "Query") && can(perms, "Account", "Get")) out.push("accounts");
|
||||||
|
if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether to offer Administration at all: when there is a section to open. */
|
||||||
|
export function hasAdministration(perms: Permissions): boolean {
|
||||||
|
return adminSections(perms).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an administrator holds, at the least: Stalwart's built-in Tenant
|
||||||
|
* Administrator role, for the parts of it that manage people and domains.
|
||||||
|
* Anyone who has all of this can already do anything to the accounts an
|
||||||
|
* "Administrator" account could.
|
||||||
|
*/
|
||||||
|
export const ADMIN_BASELINE: readonly string[] = (["Account", "Domain", "Role", "MailingList"] as const).flatMap((o) =>
|
||||||
|
(["Get", "Query", "Create", "Update", "Destroy"] as const).map((op) => `sys${o}${op}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type UserRoles = { "@type": "User" } | { "@type": "Admin" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
|
||||||
|
|
||||||
|
export type PermissionsMode =
|
||||||
|
| { "@type": "Inherit" }
|
||||||
|
| { "@type": "Merge" | "Replace"; enabledPermissions?: Record<string, boolean>; disabledPermissions?: Record<string, boolean> };
|
||||||
|
|
||||||
|
export interface RoleDef {
|
||||||
|
id: string;
|
||||||
|
description?: string | null;
|
||||||
|
enabledPermissions?: Record<string, boolean>;
|
||||||
|
roleIds?: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an account can do something the viewer cannot.
|
||||||
|
*
|
||||||
|
* Stalwart checks that a caller holds every permission they grant -- when
|
||||||
|
* roles or permissions change, and when an account is created. It does not
|
||||||
|
* check when only a password changes, and it does not check a delete. So an
|
||||||
|
* account allowed to edit accounts could reset the password of one with far
|
||||||
|
* more rights than its own and sign in as it. ihasmail refuses to offer that,
|
||||||
|
* and treats such an account as read-only.
|
||||||
|
*
|
||||||
|
* It errs towards refusing. A role that cannot be read -- the viewer lacks
|
||||||
|
* `sysRoleGet`, or the id is not in the list -- counts as outranking, because
|
||||||
|
* an unknown grant is not a grant the viewer can be shown to hold. What it
|
||||||
|
* cannot see is tenancy: an "Administrator" account is a tenant administrator
|
||||||
|
* inside a tenant and a server administrator outside one, and a tenant-scoped
|
||||||
|
* viewer is not told which it is looking at. It never sees the second kind,
|
||||||
|
* which is why comparing against the administrator baseline is enough there.
|
||||||
|
*/
|
||||||
|
export function outranks(
|
||||||
|
viewer: Permissions,
|
||||||
|
target: { roles?: UserRoles | null; permissions?: PermissionsMode | null },
|
||||||
|
roles: ReadonlyMap<string, RoleDef> | null,
|
||||||
|
): boolean {
|
||||||
|
let granted = new Set<string>();
|
||||||
|
const kind = target.roles?.["@type"] ?? "User";
|
||||||
|
if (kind === "Admin") {
|
||||||
|
if (!ADMIN_BASELINE.every((p) => viewer.has(p))) return true;
|
||||||
|
} else if (kind === "Custom") {
|
||||||
|
const ids = Object.keys((target.roles as { roleIds?: Record<string, boolean> }).roleIds ?? {});
|
||||||
|
const resolved = resolveRoles(ids, roles);
|
||||||
|
if (!resolved) return true;
|
||||||
|
granted = resolved;
|
||||||
|
}
|
||||||
|
const mode = target.permissions;
|
||||||
|
if (mode && mode["@type"] !== "Inherit") {
|
||||||
|
const enabled = Object.keys(mode.enabledPermissions ?? {});
|
||||||
|
granted = mode["@type"] === "Replace" ? new Set(enabled) : new Set([...granted, ...enabled]);
|
||||||
|
}
|
||||||
|
for (const p of granted) if (!viewer.has(p)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every permission a set of roles grants, nested roles included; null if any cannot be read. */
|
||||||
|
export function resolveRoles(ids: readonly string[], roles: ReadonlyMap<string, RoleDef> | null): Set<string> | null {
|
||||||
|
if (!ids.length) return new Set();
|
||||||
|
if (!roles) return null;
|
||||||
|
const out = new Set<string>();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const walk = (id: string): boolean => {
|
||||||
|
if (seen.has(id)) return true;
|
||||||
|
seen.add(id);
|
||||||
|
const role = roles.get(id);
|
||||||
|
if (!role) return false;
|
||||||
|
for (const p of Object.keys(role.enabledPermissions ?? {})) out.add(p);
|
||||||
|
return Object.keys(role.roleIds ?? {}).every(walk);
|
||||||
|
};
|
||||||
|
return ids.every(walk) ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the viewer could grant a role: they hold everything it carries. */
|
||||||
|
export function canGrantRole(viewer: Permissions, roleId: string, roles: ReadonlyMap<string, RoleDef> | null): boolean {
|
||||||
|
const granted = resolveRoles([roleId], roles);
|
||||||
|
return granted !== null && [...granted].every((p) => viewer.has(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A password to hand to somebody who will change it.
|
||||||
|
*
|
||||||
|
* Twenty characters from an alphabet without the ones people misread aloud
|
||||||
|
* (0/O, 1/l/I), in groups of five. Rejection sampling, so every character is
|
||||||
|
* equally likely rather than the first few of the alphabet slightly more.
|
||||||
|
*/
|
||||||
|
const ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
export function generatePassword(random: (n: number) => Uint8Array = (n) => crypto.getRandomValues(new Uint8Array(n))): string {
|
||||||
|
const out: string[] = [];
|
||||||
|
const limit = 256 - (256 % ALPHABET.length);
|
||||||
|
while (out.length < 20) {
|
||||||
|
for (const byte of random(32)) {
|
||||||
|
if (byte < limit && out.length < 20) out.push(ALPHABET[byte % ALPHABET.length]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [0, 5, 10, 15].map((i) => out.slice(i, i + 5).join("")).join("-");
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { client } from "@/jmap/client";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
import type { PermissionsMode, RoleDef, UserRoles } from "@/lib/adminAccess";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stalwart 0.16's directory, over the ordinary JMAP proxy.
|
||||||
|
*
|
||||||
|
* 0.16 removed the REST management API (`/api/principal` and the rest); people,
|
||||||
|
* domains and roles are registry objects now, read and written with `x:Account`,
|
||||||
|
* `x:Domain` and `x:Role`. These go through `/api/jmap` like every other call,
|
||||||
|
* authenticated as the signed-in account, so ihasmail holds nothing new: no
|
||||||
|
* route of its own, no store, no cache beyond the component showing the list.
|
||||||
|
*
|
||||||
|
* Shapes, from the 0.16.22 source:
|
||||||
|
*
|
||||||
|
* - A list (credentials, aliases) is an object keyed by index, `{"0": …}`. A
|
||||||
|
* set (memberGroupIds, role ids, permissions) is `{"id": true}`.
|
||||||
|
* - An account's `name` is its local part, and its domain is a `domainId`.
|
||||||
|
* `emailAddress` and `usedDiskQuota` are computed by the server.
|
||||||
|
* - Secrets read back masked. A new password is written to the existing
|
||||||
|
* password credential, so its id -- which OAuth tokens are tied to -- stays.
|
||||||
|
* - Filters are AND only, keyed by property name as it appears on the object
|
||||||
|
* (`@type`, not `type`), and the default order is newest first.
|
||||||
|
*
|
||||||
|
* Query and get are two requests rather than one with a result reference.
|
||||||
|
* Whether the registry methods resolve back-references has not been checked on
|
||||||
|
* a live server, and a list that loads a moment slower is a better failure than
|
||||||
|
* one that never loads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface EmailAlias {
|
||||||
|
enabled?: boolean;
|
||||||
|
name: string;
|
||||||
|
domainId: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Credential {
|
||||||
|
"@type": "Password" | "AppPassword" | "ApiKey";
|
||||||
|
secret?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectoryAccount {
|
||||||
|
id: string;
|
||||||
|
"@type": "User" | "Group";
|
||||||
|
name: string;
|
||||||
|
domainId: string;
|
||||||
|
emailAddress?: string;
|
||||||
|
description?: string | null;
|
||||||
|
roles?: UserRoles;
|
||||||
|
permissions?: PermissionsMode;
|
||||||
|
quotas?: Record<string, number>;
|
||||||
|
usedDiskQuota?: number;
|
||||||
|
aliases?: Record<string, EmailAlias>;
|
||||||
|
memberGroupIds?: Record<string, boolean>;
|
||||||
|
credentials?: Record<string, Credential>;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectoryDomain {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCOUNT_PROPERTIES = [
|
||||||
|
"@type", "name", "domainId", "emailAddress", "description", "roles", "permissions", "quotas",
|
||||||
|
"usedDiskQuota", "aliases", "memberGroupIds", "credentials", "createdAt",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The one quota ihasmail edits; the others keep whatever they had. */
|
||||||
|
export const DISK_QUOTA = "maxDiskQuota";
|
||||||
|
|
||||||
|
/** An error with a SetError behind it, kept so the caller can explain it. */
|
||||||
|
export class DirectoryError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly type: string,
|
||||||
|
readonly description: string | undefined,
|
||||||
|
readonly properties: string[] = [],
|
||||||
|
) {
|
||||||
|
super(description ?? type);
|
||||||
|
this.name = "DirectoryError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueryResult {
|
||||||
|
ids: string[];
|
||||||
|
total?: number;
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryAccounts(opts: { type: "User" | "Group"; text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
|
||||||
|
// The registry names the discriminator `@type`, as it is on the object. A
|
||||||
|
// plain `type` is not a property it knows and fails the whole query.
|
||||||
|
const filter: Record<string, unknown> = { "@type": opts.type };
|
||||||
|
if (opts.text?.trim()) filter.text = opts.text.trim();
|
||||||
|
const res = await client.call<QueryResult>("x:Account/query", {
|
||||||
|
filter,
|
||||||
|
position: opts.position ?? 0,
|
||||||
|
...(opts.limit ? { limit: opts.limit } : {}),
|
||||||
|
calculateTotal: true,
|
||||||
|
});
|
||||||
|
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccounts(ids: string[]): Promise<DirectoryAccount[]> {
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const res = await client.call<{ list: DirectoryAccount[] }>("x:Account/get", { ids, properties: ACCOUNT_PROPERTIES });
|
||||||
|
// In the order the query gave, which is the order the list is shown in.
|
||||||
|
const byId = new Map(res.list.map((a) => [a.id, a]));
|
||||||
|
return ids.map((id) => byId.get(id)).filter((a): a is DirectoryAccount => Boolean(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every one of a kind, for the pickers. Capped by what the server allows in a get. */
|
||||||
|
async function all<T>(object: "Domain" | "Role", properties: string[]): Promise<T[]> {
|
||||||
|
const q = await client.call<QueryResult>(`x:${object}/query`, { limit: client.maxObjectsInGet });
|
||||||
|
if (!q.ids?.length) return [];
|
||||||
|
const res = await client.call<{ list: T[] }>(`x:${object}/get`, { ids: q.ids, properties });
|
||||||
|
return res.list;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listDomains = () => all<DirectoryDomain>("Domain", ["name"]);
|
||||||
|
export const listRoles = () => all<RoleDef>("Role", ["description", "enabledPermissions", "roleIds"]);
|
||||||
|
|
||||||
|
export async function listGroups(): Promise<DirectoryAccount[]> {
|
||||||
|
const q = await queryAccounts({ type: "Group", limit: client.maxObjectsInGet });
|
||||||
|
if (!q.ids.length) return [];
|
||||||
|
const res = await client.call<{ list: DirectoryAccount[] }>("x:Account/get", { ids: q.ids, properties: ["name", "emailAddress", "description"] });
|
||||||
|
return res.list;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined>;
|
||||||
|
|
||||||
|
function throwIfRefused(res: SetResponse, kind: "notCreated" | "notUpdated" | "notDestroyed"): void {
|
||||||
|
const failure = Object.values(res[kind] ?? {})[0];
|
||||||
|
if (failure) throw new DirectoryError(failure.type, failure.description, failure.properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewAccount {
|
||||||
|
name: string;
|
||||||
|
domainId: string;
|
||||||
|
description: string;
|
||||||
|
password: string;
|
||||||
|
roles: UserRoles;
|
||||||
|
diskQuotaBytes: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAccount(input: NewAccount): Promise<string> {
|
||||||
|
const res = await client.call<SetResponse & { created?: Record<string, { id: string }> }>("x:Account/set", {
|
||||||
|
create: {
|
||||||
|
n: {
|
||||||
|
"@type": "User",
|
||||||
|
name: input.name.trim(),
|
||||||
|
domainId: input.domainId,
|
||||||
|
description: input.description.trim() || null,
|
||||||
|
credentials: { "0": { "@type": "Password", secret: input.password } },
|
||||||
|
roles: input.roles,
|
||||||
|
permissions: { "@type": "Inherit" },
|
||||||
|
quotas: input.diskQuotaBytes ? { [DISK_QUOTA]: input.diskQuotaBytes } : {},
|
||||||
|
aliases: {},
|
||||||
|
memberGroupIds: {},
|
||||||
|
// Required on create. Turning it on is one-way and not offered here.
|
||||||
|
encryptionAtRest: { "@type": "Disabled" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throwIfRefused(res, "notCreated");
|
||||||
|
const id = res.created?.n?.id;
|
||||||
|
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the account was created."));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAccount(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||||
|
if (!Object.keys(patch).length) return;
|
||||||
|
const res = await client.call<SetResponse>("x:Account/set", { update: { [id]: patch } });
|
||||||
|
throwIfRefused(res, "notUpdated");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroyAccount(id: string): Promise<void> {
|
||||||
|
const res = await client.call<SetResponse>("x:Account/set", { destroy: [id] });
|
||||||
|
throwIfRefused(res, "notDestroyed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The patch that sets a new password.
|
||||||
|
*
|
||||||
|
* Into the existing password credential when there is one, which keeps its
|
||||||
|
* credential id; as a new credential after the last index when there is not --
|
||||||
|
* an account that has only ever signed in through a directory, say. An account
|
||||||
|
* holds one password at most, so adding a second is never the answer.
|
||||||
|
*/
|
||||||
|
export function passwordPatch(account: Pick<DirectoryAccount, "credentials">, secret: string): Record<string, unknown> {
|
||||||
|
const entries = Object.entries(account.credentials ?? {});
|
||||||
|
const existing = entries.find(([, c]) => c["@type"] === "Password");
|
||||||
|
if (existing) return { [`credentials/${existing[0]}/secret`]: secret };
|
||||||
|
const next = entries.reduce((max, [k]) => Math.max(max, Number(k) + 1), 0);
|
||||||
|
return { [`credentials/${next}`]: { "@type": "Password", secret } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPassword(account: Pick<DirectoryAccount, "credentials">): boolean {
|
||||||
|
return Object.values(account.credentials ?? {}).some((c) => c["@type"] === "Password");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-index a list of aliases the way the server stores them. */
|
||||||
|
export function aliasList(aliases: EmailAlias[]): Record<string, EmailAlias> {
|
||||||
|
return Object.fromEntries(aliases.map((a, i) => [String(i), { enabled: a.enabled ?? true, name: a.name, domainId: a.domainId, description: a.description ?? null }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The quotas object with the disk limit set or cleared, and every other quota kept. */
|
||||||
|
export function quotasWithDisk(quotas: Record<string, number> | undefined, bytes: number | null): Record<string, number> {
|
||||||
|
const next = { ...(quotas ?? {}) };
|
||||||
|
if (bytes && bytes > 0) next[DISK_QUOTA] = bytes;
|
||||||
|
else delete next[DISK_QUOTA];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's own wording for a value one of its validators refused, and
|
||||||
|
* what to say instead. These come from the registry's string validators
|
||||||
|
* (`crates/registry/src/types/string.rs`), which is the whole list: anything
|
||||||
|
* else Stalwart says about a value is picked up by the fallback below.
|
||||||
|
*/
|
||||||
|
const VALIDATOR_MESSAGES: Record<string, () => string> = {
|
||||||
|
"Invalid domain name": () => t("That isn't a valid domain name. Use a name such as example.com, on a real top-level domain."),
|
||||||
|
"Invalid email address": () => t("That isn't a valid email address. Use a full address, such as [email protected]."),
|
||||||
|
"Invalid email local part": () => t("That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @."),
|
||||||
|
"Invalid hostname or IP address": () => t("That isn't a valid host name or IP address."),
|
||||||
|
"String cannot be empty": () => t("A required value was left empty."),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What kind of thing a refusal was about, where the wording has to differ. */
|
||||||
|
export type DirectoryObject = "account" | "domain";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Say what went wrong in terms of the person's own action, in their language.
|
||||||
|
*
|
||||||
|
* Stalwart explains a refusal in English, and its words are never shown as
|
||||||
|
* they are: an interface in German that answers in English reads as broken
|
||||||
|
* even when the English is exact. Every type the registry returns has its
|
||||||
|
* own message, and a value a validator refused is recognised by the
|
||||||
|
* validator's wording and said again here.
|
||||||
|
*
|
||||||
|
* One exception, on purpose. A password policy is the server's to set -- a
|
||||||
|
* length, a strength -- and there is no way to know its rule in advance to
|
||||||
|
* translate it, so its reason is kept after a translated sentence. Dropping it
|
||||||
|
* would leave "not accepted" with no way to find out why.
|
||||||
|
*/
|
||||||
|
export function describeDirectoryError(err: unknown, object: DirectoryObject = "account"): string {
|
||||||
|
if (!(err instanceof DirectoryError)) {
|
||||||
|
const e = err as { type?: string; code?: string; status?: number };
|
||||||
|
// ihasmail's own proxy, refusing for this session or this installation.
|
||||||
|
if (e?.code === "administration_needs_own_device") return t("Only on a device you've marked as your own. Sign in again with “This is my own device” ticked.");
|
||||||
|
if (e?.code === "administration_disabled") return t("Administration is turned off on this installation.");
|
||||||
|
if (e?.code === "network_error" || e?.status === 0) return t("Network error. Please check your connection.");
|
||||||
|
if (e?.code === "rate_limited" || e?.status === 429) return t("Too many attempts. Please wait a few minutes and try again.");
|
||||||
|
// A method-level JMAP error: the whole call was refused.
|
||||||
|
if (e?.type === "forbidden") return t("The mail server refused this. Your role may not allow it.");
|
||||||
|
if (e?.type) return t("The mail server could not carry out the request ({code}).", { code: e.type });
|
||||||
|
return t("The mail server could not carry out the request ({code}).", { code: e?.code ?? "error" });
|
||||||
|
}
|
||||||
|
const description = err.description ?? "";
|
||||||
|
switch (err.type) {
|
||||||
|
case "forbidden":
|
||||||
|
if (/not authorized to grant/i.test(description)) return t("You can't give an account permissions your own role doesn't have.");
|
||||||
|
if (/external directory/i.test(description)) return t("This account signs in through an external directory, so its password can't be set here.");
|
||||||
|
if (/licen[cs]ed account limit/i.test(description)) return t("The server's licence allows no more accounts.");
|
||||||
|
return t("The mail server refused this. Your role may not allow it.");
|
||||||
|
case "primaryKeyViolation":
|
||||||
|
return object === "domain"
|
||||||
|
? t("That domain name is already in use on this server, as a domain or another domain's other name.")
|
||||||
|
: t("That address is already in use on this server, as an account, a list or an alias.");
|
||||||
|
case "invalidForeignKey":
|
||||||
|
return t("One of the chosen domain, role or group can't be used for this account.");
|
||||||
|
case "overQuota":
|
||||||
|
return object === "domain" ? t("Your organisation has reached the number of domains it is allowed.") : t("Your organisation has reached the number of accounts it is allowed.");
|
||||||
|
case "objectIsLinked":
|
||||||
|
return t("Something still depends on this, so the server kept it.");
|
||||||
|
case "notFound":
|
||||||
|
return object === "domain" ? t("This domain no longer exists. Someone may have removed it.") : t("This account no longer exists. Someone may have deleted it.");
|
||||||
|
case "rateLimit":
|
||||||
|
return t("Too many attempts. Please wait a few minutes and try again.");
|
||||||
|
case "tooLarge":
|
||||||
|
return t("That is more than the mail server accepts in one change.");
|
||||||
|
case "invalidPatch":
|
||||||
|
case "invalidProperties":
|
||||||
|
case "validationFailed": {
|
||||||
|
if (err.properties.includes("secret")) {
|
||||||
|
return description ? t("The password was not accepted: {reason}", { reason: description }) : t("The password was not accepted.");
|
||||||
|
}
|
||||||
|
const known = VALIDATOR_MESSAGES[description];
|
||||||
|
if (known) return known();
|
||||||
|
return t("The mail server rejected one of the values. Check what you entered and try again.");
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return t("The mail server refused the change ({code}).", { code: err.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { client } from "@/jmap/client";
|
||||||
|
import { plural, t } from "@/lib/i18n";
|
||||||
|
import { DirectoryError } from "@/lib/adminDirectory";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stalwart 0.16's domains, over the same proxy as accounts.
|
||||||
|
*
|
||||||
|
* From the 0.16.22 source (`Domain`, `DkimSignature`, and the registry's get):
|
||||||
|
*
|
||||||
|
* - `aliases` are other names for the domain, a set: `{"example.net": true}`.
|
||||||
|
* - `dkimManagement`, `dnsManagement` and `certificateManagement` are each
|
||||||
|
* `{"@type": "Manual"}` or `{"@type": "Automatic", …}`. A new domain gets
|
||||||
|
* automatic DKIM and manual DNS and certificates unless told otherwise.
|
||||||
|
* - `dnsZoneFile` is computed on read: every record the server wants published
|
||||||
|
* for the domain, as BIND lines.
|
||||||
|
* - A DKIM key is created with its private key, which the server validates;
|
||||||
|
* with automatic management it makes and rotates them itself.
|
||||||
|
* - Deleting a domain anything still points at is refused with
|
||||||
|
* `objectIsLinked` and the list of what does -- including the domain's own
|
||||||
|
* DKIM keys, which is why removing one means removing those first.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Managed {
|
||||||
|
"@type": "Manual" | "Automatic";
|
||||||
|
dnsServerId?: string;
|
||||||
|
acmeProviderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectoryDomainFull {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
aliases?: Record<string, boolean>;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
description?: string | null;
|
||||||
|
catchAllAddress?: string | null;
|
||||||
|
subAddressing?: { "@type": "Enabled" | "Disabled" | "Custom" };
|
||||||
|
dkimManagement?: Managed;
|
||||||
|
dnsManagement?: Managed;
|
||||||
|
certificateManagement?: Managed;
|
||||||
|
memberTenantId?: string | null;
|
||||||
|
directoryId?: string | null;
|
||||||
|
dnsZoneFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DkimKey {
|
||||||
|
id: string;
|
||||||
|
"@type": string;
|
||||||
|
selector: string;
|
||||||
|
stage?: "active" | "pending" | "retiring" | "retired";
|
||||||
|
createdAt?: string;
|
||||||
|
nextTransitionAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOMAIN_PROPERTIES = [
|
||||||
|
"name", "aliases", "isEnabled", "createdAt", "description", "catchAllAddress", "subAddressing",
|
||||||
|
"dkimManagement", "dnsManagement", "certificateManagement", "memberTenantId", "directoryId",
|
||||||
|
];
|
||||||
|
|
||||||
|
type SetFailure = { type: string; description?: string; properties?: string[]; linkedObjects?: { object?: string; id?: string }[] };
|
||||||
|
type SetResponse = Record<string, Record<string, SetFailure | null | { id: string }> | undefined>;
|
||||||
|
|
||||||
|
/** A refusal, with what the server said still depends on the object. */
|
||||||
|
export class DomainError extends DirectoryError {
|
||||||
|
constructor(failure: SetFailure) {
|
||||||
|
super(failure.type, failure.description, failure.properties);
|
||||||
|
this.linked = (failure.linkedObjects ?? []).map((o) => String(o.object ?? ""));
|
||||||
|
}
|
||||||
|
readonly linked: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function refused(res: SetResponse, kind: "notCreated" | "notUpdated" | "notDestroyed"): void {
|
||||||
|
const failure = Object.values(res[kind] ?? {})[0] as SetFailure | undefined;
|
||||||
|
if (failure) throw new DomainError(failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryDomains(opts: { text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
|
||||||
|
const filter: Record<string, unknown> = {};
|
||||||
|
if (opts.text?.trim()) filter.text = opts.text.trim().toLowerCase();
|
||||||
|
const res = await client.call<{ ids: string[]; total?: number }>("x:Domain/query", {
|
||||||
|
filter,
|
||||||
|
position: opts.position ?? 0,
|
||||||
|
...(opts.limit ? { limit: opts.limit } : {}),
|
||||||
|
calculateTotal: true,
|
||||||
|
});
|
||||||
|
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDomains(ids: string[], opts: { zoneFile?: boolean } = {}): Promise<DirectoryDomainFull[]> {
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const properties = opts.zoneFile ? [...DOMAIN_PROPERTIES, "dnsZoneFile"] : DOMAIN_PROPERTIES;
|
||||||
|
const res = await client.call<{ list: DirectoryDomainFull[] }>("x:Domain/get", { ids, properties });
|
||||||
|
const byId = new Map(res.list.map((d) => [d.id, d]));
|
||||||
|
return ids.map((id) => byId.get(id)).filter((d): d is DirectoryDomainFull => Boolean(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many accounts live on each domain. One query per domain, batched into as
|
||||||
|
* few requests as the server allows; a count that fails is left out rather
|
||||||
|
* than shown as zero, which would read as "safe to delete".
|
||||||
|
*/
|
||||||
|
export async function countAccounts(domainIds: string[]): Promise<Map<string, number>> {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
await Promise.all(
|
||||||
|
domainIds.map((domainId) =>
|
||||||
|
client
|
||||||
|
.call<{ total?: number; ids?: string[] }>("x:Account/query", { filter: { domainId }, limit: 1, calculateTotal: true })
|
||||||
|
.then((r) => { if (typeof r.total === "number") counts.set(domainId, r.total); })
|
||||||
|
.catch(() => {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDkimKeys(domainId: string): Promise<DkimKey[]> {
|
||||||
|
const q = await client.call<{ ids: string[] }>("x:DkimSignature/query", { filter: { domainId } });
|
||||||
|
if (!q.ids?.length) return [];
|
||||||
|
const res = await client.call<{ list: DkimKey[] }>("x:DkimSignature/get", { ids: q.ids, properties: ["@type", "selector", "stage", "createdAt", "nextTransitionAt"] });
|
||||||
|
return res.list;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function namesOf(object: "Tenant" | "DnsServer", ids: string[]): Promise<Map<string, string>> {
|
||||||
|
if (!ids.length) return new Map();
|
||||||
|
const property = object === "Tenant" ? "name" : "description";
|
||||||
|
const res = await client.call<{ list: Array<{ id: string } & Record<string, unknown>> }>(`x:${object}/get`, { ids, properties: [property] });
|
||||||
|
return new Map(res.list.map((o) => [o.id, String(o[property] ?? o.id)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lower-case, no surrounding space or root dot: how a domain is written back. */
|
||||||
|
export function normaliseDomain(name: string): string {
|
||||||
|
return name.trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enough of a check to catch a typo before the server does; the server decides. */
|
||||||
|
export function looksLikeDomain(name: string): boolean {
|
||||||
|
return /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/.test(normaliseDomain(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDomain(input: { name: string; description: string }): Promise<string> {
|
||||||
|
const res = await client.call<SetResponse>("x:Domain/set", {
|
||||||
|
create: { n: { name: normaliseDomain(input.name), description: input.description.trim() || null } },
|
||||||
|
});
|
||||||
|
refused(res, "notCreated");
|
||||||
|
const id = (res.created?.n as { id?: string } | undefined)?.id;
|
||||||
|
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the domain was created."));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDomain(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||||
|
if (!Object.keys(patch).length) return;
|
||||||
|
const res = await client.call<SetResponse>("x:Domain/set", { update: { [id]: patch } });
|
||||||
|
refused(res, "notUpdated");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a domain, and its DKIM keys with it.
|
||||||
|
*
|
||||||
|
* The keys go first, in the same request, because the server will not remove a
|
||||||
|
* domain its keys still name. Keys that belong to a domain being removed sign
|
||||||
|
* nothing afterwards, so there is no case for keeping them.
|
||||||
|
*/
|
||||||
|
export async function destroyDomain(id: string, dkimKeyIds: string[]): Promise<void> {
|
||||||
|
if (dkimKeyIds.length) {
|
||||||
|
const keys = await client.call<SetResponse>("x:DkimSignature/set", { destroy: dkimKeyIds });
|
||||||
|
refused(keys, "notDestroyed");
|
||||||
|
}
|
||||||
|
const res = await client.call<SetResponse>("x:Domain/set", { destroy: [id] });
|
||||||
|
refused(res, "notDestroyed");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DnsRecord {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
/** The line as the zone file had it, for copying into a BIND zone. */
|
||||||
|
line: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the zone file Stalwart computes for a domain.
|
||||||
|
*
|
||||||
|
* Its serialiser writes one record per line as `name IN TYPE value`, and a TXT
|
||||||
|
* record longer than 255 bytes as a parenthesised run of quoted strings, one
|
||||||
|
* per line. A DNS provider's form wants the whole value, so the strings are
|
||||||
|
* joined and unescaped; the original lines are kept for anyone pasting into a
|
||||||
|
* zone. Anything that does not parse is kept too, as its own row, rather than
|
||||||
|
* silently dropped from a list somebody is copying from.
|
||||||
|
*/
|
||||||
|
export function parseZoneFile(text: string): DnsRecord[] {
|
||||||
|
const out: DnsRecord[] = [];
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
let line = lines[i]!;
|
||||||
|
if (!line.trim() || line.trim().startsWith(";")) continue;
|
||||||
|
if (line.includes("(") && !line.includes(")")) {
|
||||||
|
while (i + 1 < lines.length && !lines[i]!.includes(")")) line += `\n${lines[++i]}`;
|
||||||
|
}
|
||||||
|
const m = /^(\S+)\s+(?:\d+\s+)?(?:IN\s+)?([A-Z]+)\s+([\s\S]*)$/.exec(line.trim());
|
||||||
|
if (!m) {
|
||||||
|
out.push({ name: "", type: "", value: line.trim(), line: line.trim() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [, name, type, rest] = m;
|
||||||
|
let value = rest!.trim();
|
||||||
|
if (type === "TXT") {
|
||||||
|
const parts = [...value.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((p) => p[1]!.replace(/\\(.)/g, "$1"));
|
||||||
|
if (parts.length) value = parts.join("");
|
||||||
|
}
|
||||||
|
out.push({ name: name!.replace(/\.$/, ""), type: type!, value, line: line.trim() });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A readable name for a DKIM key's algorithm, from its `@type`. */
|
||||||
|
export function dkimAlgorithm(type: string): string {
|
||||||
|
const version = /^Dkim2/.test(type) ? "DKIM2" : "DKIM1";
|
||||||
|
const algo = /Ed25519/i.test(type) ? "Ed25519" : /Rsa/i.test(type) ? "RSA" : type;
|
||||||
|
return `${algo} · ${version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What still points at an object, counted by kind, for a refusal message. */
|
||||||
|
export function describeLinked(linked: string[]): string {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const kind of linked) counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const [kind, n] of counts) {
|
||||||
|
if (kind === "Account") parts.push(plural(n, { one: "{n} account", other: "{n} accounts" }));
|
||||||
|
else if (kind === "MailingList") parts.push(plural(n, { one: "{n} mailing list", other: "{n} mailing lists" }));
|
||||||
|
else if (kind === "DkimSignature") parts.push(plural(n, { one: "{n} DKIM key", other: "{n} DKIM keys" }));
|
||||||
|
else parts.push(plural(n, { one: "{n} other item", other: "{n} other items" }));
|
||||||
|
}
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
@@ -557,3 +557,48 @@ export function localeOptions(): LocaleOption[] {
|
|||||||
optionsExtras = extras;
|
optionsExtras = extras;
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weekday names in the reader's locale, indexed by JSCalendar's two-letter day.
|
||||||
|
*
|
||||||
|
* These used to be a table of English strings with a `short` of "M", "T", "W"…
|
||||||
|
* which could not become catalogue entries at all: "T" is both Tuesday and
|
||||||
|
* Thursday and "S" is both Saturday and Sunday, so the key collides with
|
||||||
|
* itself. A catalogue cannot hold two translations under one key, and no
|
||||||
|
* amount of translating fixes that — the data was wrong, not the wiring.
|
||||||
|
*
|
||||||
|
* Intl has the names already, in every locale, in three widths, and gets the
|
||||||
|
* plural and capitalisation conventions right without anybody maintaining a
|
||||||
|
* list. 2026-06-01 is a Monday; the rest follow from it.
|
||||||
|
*/
|
||||||
|
export type WeekdayKey = "mo" | "tu" | "we" | "th" | "fr" | "sa" | "su";
|
||||||
|
|
||||||
|
const WEEKDAY_ORDER: WeekdayKey[] = ["mo", "tu", "we", "th", "fr", "sa", "su"];
|
||||||
|
const WEEKDAY_BASE = Date.UTC(2026, 5, 1); // a Monday
|
||||||
|
|
||||||
|
export function weekdayName(day: WeekdayKey, width: "long" | "short" | "narrow" = "long"): string {
|
||||||
|
const i = WEEKDAY_ORDER.indexOf(day);
|
||||||
|
if (i < 0) return day;
|
||||||
|
return intl({ weekday: width, timeZone: "UTC" }).format(new Date(WEEKDAY_BASE + i * 86_400_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every weekday, Monday first, for pickers that show all seven. */
|
||||||
|
export function weekdayNames(width: "long" | "short" | "narrow" = "long"): Array<{ key: WeekdayKey; name: string }> {
|
||||||
|
return WEEKDAY_ORDER.map((key) => ({ key, name: weekdayName(key, width) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "A, B and C" — or "A, B oder C", or the comma the locale actually uses.
|
||||||
|
*
|
||||||
|
* Joining with a translated " and " does not work: Japanese does not separate
|
||||||
|
* list items with a word, and the last separator differs from the others in
|
||||||
|
* English. Intl.ListFormat knows all of that.
|
||||||
|
*/
|
||||||
|
export function formatList(items: string[], type: "conjunction" | "disjunction" = "conjunction"): string {
|
||||||
|
if (items.length < 2) return items[0] ?? "";
|
||||||
|
try {
|
||||||
|
return new Intl.ListFormat(resolvedLocale(), { style: "long", type }).format(items);
|
||||||
|
} catch {
|
||||||
|
return items.join(", ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -191,12 +191,29 @@ export const EMAIL_BASE_CSS = `
|
|||||||
.ihm-email-root.themed a { color: var(--link, #0f766e); }
|
.ihm-email-root.themed a { color: var(--link, #0f766e); }
|
||||||
.ihm-email-root.themed hr { border-color: var(--border, #e3e7ec); }
|
.ihm-email-root.themed hr { border-color: var(--border, #e3e7ec); }
|
||||||
.ihm-email-root.themed img[data-ihm-blocked] { background: var(--bg-sunken, #f1f5f9) repeating-linear-gradient(45deg, var(--bg-hover, #e2e8f0) 0 6px, transparent 6px 12px); border-color: var(--border-strong, #cbd5e1); }
|
.ihm-email-root.themed img[data-ihm-blocked] { background: var(--bg-sunken, #f1f5f9) repeating-linear-gradient(45deg, var(--bg-hover, #e2e8f0) 0 6px, transparent 6px 12px); border-color: var(--border-strong, #cbd5e1); }
|
||||||
|
|
||||||
|
/* "Even mail that styles itself" — the second, opt-in switch, applied on top of
|
||||||
|
.themed. Everything the sender coloured is neutralised except the surfaces
|
||||||
|
marked by markKeptSurfaces() and what it marked as sitting on them, so a
|
||||||
|
white wrapper table
|
||||||
|
stops being a bright card while a blue button keeps its white label. The
|
||||||
|
sender's markup is untouched; this is all cascade, so the switch is
|
||||||
|
reversible and print still pins the tokens to ink on white. */
|
||||||
|
.ihm-email-root.forced { color: var(--fg, #1f2937) !important; background: var(--bg-elev, #fff) !important; }
|
||||||
|
.ihm-email-root.forced *:not([data-ihm-keep]):not([data-ihm-in-keep]) { color: inherit !important; background-color: transparent !important; }
|
||||||
|
.ihm-email-root.forced a:not([data-ihm-keep]):not([data-ihm-in-keep]) { color: var(--link, #0f766e) !important; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Does this message paint itself? Mail that sets a background or text colour
|
* Does this message paint itself? Mail that sets a background or text colour
|
||||||
* has a design of its own, and forcing a dark palette on half of it is worse
|
* has a design of its own, and forcing a dark palette on half of it is worse
|
||||||
* than leaving it alone — so those keep the light card they were built for.
|
* than leaving it alone — so those keep the light card they were built for.
|
||||||
|
*
|
||||||
|
* The bar is deliberately low, and that is the point of the second switch
|
||||||
|
* (`themeStyledMessages`): in real mail this is true of very nearly everything.
|
||||||
|
* One `color:#FFFFFF` on one button label is enough, so a template that is
|
||||||
|
* plain in every way a reader would notice still counts as painting itself.
|
||||||
|
* See `markKeptSurfaces` for what the opt-in does about it.
|
||||||
*/
|
*/
|
||||||
export function htmlDeclaresColors(html: string, bodyStyle = ""): boolean {
|
export function htmlDeclaresColors(html: string, bodyStyle = ""): boolean {
|
||||||
const haystack = `${bodyStyle} ${html}`;
|
const haystack = `${bodyStyle} ${html}`;
|
||||||
@@ -207,6 +224,152 @@ export function htmlDeclaresColors(html: string, bodyStyle = ""): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- forcing the theme onto mail that styles itself ---------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative luminance per WCAG 2.x, or `null` when the colour cannot be read.
|
||||||
|
*
|
||||||
|
* Only what actually turns up in mail is parsed: hex in three, six or eight
|
||||||
|
* digits, `rgb()`/`rgba()`, and the handful of names senders still write out.
|
||||||
|
* Anything else is `null`, which the caller treats as "not a deliberate
|
||||||
|
* surface" — the safe way round, because the failure it avoids is a white
|
||||||
|
* sheet surviving the switch the reader just turned on.
|
||||||
|
*/
|
||||||
|
const NAMED: Record<string, string> = {
|
||||||
|
white: "#ffffff", ivory: "#fffff0", snow: "#fffafa", whitesmoke: "#f5f5f5",
|
||||||
|
ghostwhite: "#f8f8ff", floralwhite: "#fffaf0", seashell: "#fff5ee", beige: "#f5f5dc",
|
||||||
|
linen: "#faf0e6", lightgray: "#d3d3d3", lightgrey: "#d3d3d3", gainsboro: "#dcdcdc",
|
||||||
|
silver: "#c0c0c0", gray: "#808080", grey: "#808080", black: "#000000",
|
||||||
|
navy: "#000080", darkblue: "#00008b", maroon: "#800000", teal: "#008080",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function relativeLuminance(color: string): number | null {
|
||||||
|
const raw = color.trim().toLowerCase();
|
||||||
|
if (!raw || raw === "transparent" || raw === "inherit" || raw === "initial" || raw === "none") return null;
|
||||||
|
let r: number, g: number, b: number, a = 1;
|
||||||
|
const named = NAMED[raw];
|
||||||
|
const hex = (named ?? raw).match(/^#([0-9a-f]{3,8})$/);
|
||||||
|
if (hex) {
|
||||||
|
const h = hex[1]!;
|
||||||
|
if (h.length === 3) [r, g, b] = [h[0]! + h[0]!, h[1]! + h[1]!, h[2]! + h[2]!].map((x) => parseInt(x, 16)) as [number, number, number];
|
||||||
|
else if (h.length === 6 || h.length === 8) {
|
||||||
|
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
|
||||||
|
if (h.length === 8) a = parseInt(h.slice(6, 8), 16) / 255;
|
||||||
|
} else return null;
|
||||||
|
} else {
|
||||||
|
const m = raw.match(/^rgba?\(\s*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)(?:[\s,/]+([0-9.%]+))?\s*\)$/);
|
||||||
|
if (!m) return null;
|
||||||
|
r = Number(m[1]); g = Number(m[2]); b = Number(m[3]);
|
||||||
|
if (m[4] !== undefined) a = m[4].endsWith("%") ? Number(m[4].slice(0, -1)) / 100 : Number(m[4]);
|
||||||
|
}
|
||||||
|
if ([r, g, b, a].some((n) => !Number.isFinite(n))) return null;
|
||||||
|
// A fully transparent colour paints nothing, whatever its channels say.
|
||||||
|
if (a === 0) return null;
|
||||||
|
const lin = (c: number) => { const x = c / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
|
||||||
|
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Above this, a background is a sheet the message is laid on rather than a
|
||||||
|
* thing drawn on top of it. White wrappers sit at 1.0; the blue of a call to
|
||||||
|
* action lands near 0.09, mid-grey near 0.22.
|
||||||
|
*/
|
||||||
|
export const LIGHT_SURFACE_LUMINANCE = 0.5;
|
||||||
|
|
||||||
|
/** The background an element declares itself, or null if it declares none we can read. */
|
||||||
|
function declaredLuminance(el: HTMLElement): number | null {
|
||||||
|
const declared = el.getAttribute("bgcolor") ?? el.style?.backgroundColor ?? "";
|
||||||
|
if (!declared) return null;
|
||||||
|
return relativeLuminance(declared);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the surfaces that must survive being themed, and count them.
|
||||||
|
*
|
||||||
|
* The reader has asked for their palette on mail that brings its own, which
|
||||||
|
* cannot be done perfectly — this is the same bargain a dark-reader extension
|
||||||
|
* makes. What it can do is tell the two kinds of colour apart: a **sheet** the
|
||||||
|
* design sits on, which is what reads as a bright card and is neutralised, and
|
||||||
|
* a **painted surface** — a button, a banner — which is kept whole so its
|
||||||
|
* label stays legible on it.
|
||||||
|
*
|
||||||
|
* Two attributes come out of this. `data-ihm-keep` is a painted surface, which
|
||||||
|
* keeps its own colours. `data-ihm-in-keep` is an element sitting on one with
|
||||||
|
* no background of its own, whose colour is left alone so a white label on a
|
||||||
|
* blue button stays readable. One rule in EMAIL_BASE_CSS neutralises
|
||||||
|
* everything else.
|
||||||
|
*
|
||||||
|
* The distinction that matters is that being *inside* a painted surface is not
|
||||||
|
* inherited past a sheet. A light table nested in a dark 600px card is still a
|
||||||
|
* sheet and is still neutralised — that is issue #310, where a dark campaign
|
||||||
|
* rendered with beige cards inside it because the exemption used to be
|
||||||
|
* `[data-ihm-keep] *` in CSS and could not see the difference. Paint resumes
|
||||||
|
* below it: a dark button inside that nested table is kept as usual.
|
||||||
|
*
|
||||||
|
* Nothing the sender wrote is removed, so turning the switch off puts the
|
||||||
|
* message back exactly as it was — and a colour that arrived from a `<style>`
|
||||||
|
* block rather than an attribute is covered too, which is most of them in
|
||||||
|
* modern templates.
|
||||||
|
*/
|
||||||
|
export function markKeptSurfaces(root: ParentNode): number {
|
||||||
|
let kept = 0;
|
||||||
|
|
||||||
|
// An explicit stack rather than recursion: this walks untrusted mail, and
|
||||||
|
// deeply nested tables are exactly what old newsletter HTML is made of.
|
||||||
|
const stack: Array<{ el: HTMLElement; onPaint: boolean }> = [];
|
||||||
|
const push = (parent: ParentNode, onPaint: boolean) => {
|
||||||
|
for (const child of Array.from(parent.children)) {
|
||||||
|
stack.push({ el: child as HTMLElement, onPaint });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
push(root, false);
|
||||||
|
|
||||||
|
while (stack.length) {
|
||||||
|
const { el, onPaint } = stack.pop()!;
|
||||||
|
const lum = declaredLuminance(el);
|
||||||
|
let childrenOnPaint = onPaint;
|
||||||
|
|
||||||
|
if (lum !== null && lum < LIGHT_SURFACE_LUMINANCE) {
|
||||||
|
// Painted: keep it whole, and anything on it inherits that protection.
|
||||||
|
el.setAttribute("data-ihm-keep", "");
|
||||||
|
kept++;
|
||||||
|
childrenOnPaint = true;
|
||||||
|
} else if (lum !== null) {
|
||||||
|
// A sheet, wherever it sits. Left unmarked so it neutralises, and it
|
||||||
|
// ends the protection rather than passing it on.
|
||||||
|
childrenOnPaint = false;
|
||||||
|
} else if (onPaint) {
|
||||||
|
// No background of its own, sitting on paint: leave its colour alone.
|
||||||
|
el.setAttribute("data-ihm-in-keep", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
push(el, childrenOnPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a message really has an HTML alternative to render.
|
||||||
|
*
|
||||||
|
* `htmlBody` is a *derived* list, not a filter: RFC 8621 §4.1.4 says a message
|
||||||
|
* with no HTML alternative still gets one, and it holds the text/plain part.
|
||||||
|
* Confirmed live against Stalwart 0.16.21 (2026-09-10) -- a plain-text mail
|
||||||
|
* comes back with `htmlBody` and `textBody` naming the same part, typed
|
||||||
|
* `text/plain`, while a real multipart/alternative names two different parts.
|
||||||
|
*
|
||||||
|
* So "is there a body value under htmlBody" is not the question; the part's own
|
||||||
|
* type is. Answering the first one sent every plain-text message down the HTML
|
||||||
|
* path, where the body is placed in `.ihm-email-root` under
|
||||||
|
* `white-space: normal` and every line break collapses -- hard-wrapped mail
|
||||||
|
* arrived as a single paragraph with the signature and the quoted reply run
|
||||||
|
* into the prose.
|
||||||
|
*/
|
||||||
|
export function hasHtmlAlternative(part: { type?: string } | undefined, value: string | undefined): boolean {
|
||||||
|
return /^text\/html\b/i.test(part?.type ?? "") && Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
export const TEXT_EMAIL_CSS = `
|
export const TEXT_EMAIL_CSS = `
|
||||||
:host { display:block; }
|
:host { display:block; }
|
||||||
.ihm-text-root { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 13.5px; line-height:1.55; white-space: pre-wrap; overflow-wrap: anywhere; color: inherit; }
|
.ihm-text-root { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 13.5px; line-height:1.55; white-space: pre-wrap; overflow-wrap: anywhere; color: inherit; }
|
||||||
|
|||||||
+33
-3
@@ -54,9 +54,7 @@ class Keyboard {
|
|||||||
// Let modal dialogs and popovers handle their own keys (Escape, arrows, ...).
|
// Let modal dialogs and popovers handle their own keys (Escape, arrows, ...).
|
||||||
if (document.querySelector(".dialog-backdrop, .popover")) return;
|
if (document.querySelector(".dialog-backdrop, .popover")) return;
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
const inInput =
|
const inInput = isTextEntry(target);
|
||||||
!!target &&
|
|
||||||
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable);
|
|
||||||
const combo = comboOf(e);
|
const combo = comboOf(e);
|
||||||
if (!combo) return;
|
if (!combo) return;
|
||||||
|
|
||||||
@@ -102,6 +100,38 @@ class Keyboard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the focused element somewhere the reader is typing?
|
||||||
|
*
|
||||||
|
* This guard exists so that pressing "a" in the search box searches for "a"
|
||||||
|
* rather than archiving the message behind it. The test used to be
|
||||||
|
* `tagName === "INPUT"`, which is true of a checkbox — and a checkbox keeps
|
||||||
|
* focus after you click it, so ticking "select all" silently disabled every
|
||||||
|
* shortcut until the reader clicked somewhere else (#260). Nothing about a
|
||||||
|
* checkbox swallows a keystroke: space toggles it and the browser handles
|
||||||
|
* that before this listener ever runs.
|
||||||
|
*
|
||||||
|
* So the question is not "is this an input" but "does this input take text".
|
||||||
|
* A `<select>` does, in the sense that matters here: typing a letter jumps to
|
||||||
|
* the option beginning with it, which a shortcut would steal.
|
||||||
|
*/
|
||||||
|
const TEXT_ENTRY_TYPES = new Set([
|
||||||
|
"text", "search", "email", "url", "tel", "password", "number",
|
||||||
|
"date", "datetime-local", "month", "time", "week",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isTextEntry(el: Element | null): boolean {
|
||||||
|
if (!el) return false;
|
||||||
|
const node = el as HTMLElement;
|
||||||
|
if (node.isContentEditable) return true;
|
||||||
|
const tag = node.tagName;
|
||||||
|
if (tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||||
|
if (tag !== "INPUT") return false;
|
||||||
|
// An <input> with no type attribute is a text field.
|
||||||
|
const type = (node as HTMLInputElement).type?.toLowerCase() || "text";
|
||||||
|
return TEXT_ENTRY_TYPES.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||||
|
|
||||||
export function comboOf(e: KeyboardEvent): string | null {
|
export function comboOf(e: KeyboardEvent): string | null {
|
||||||
|
|||||||
@@ -105,3 +105,36 @@ export function parseLdif(text: string): LdifRecord[] {
|
|||||||
return !change || change === "add";
|
return !change || change === "add";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An identity for an entry, derived from its distinguished name.
|
||||||
|
*
|
||||||
|
* Mozilla's schema has no UID, so a re-import had nothing to be recognised by
|
||||||
|
* and duplicated everything (#223). The `dn` is what the file actually carries,
|
||||||
|
* and it does not need to be a durable identity to answer the only question
|
||||||
|
* being asked of it: have I imported this exact entry before? A migration is
|
||||||
|
* import, notice something wrong, correct the export, import again -- and the
|
||||||
|
* `dn` does not change in the ten minutes between two attempts, which is the
|
||||||
|
* interval that matters. An import is not a sync.
|
||||||
|
*
|
||||||
|
* Namespaced rather than stored raw, because it becomes the card's `uid` and
|
||||||
|
* must not be mistaken for a UID a vCard author meant. The one way this can be
|
||||||
|
* wrong: two directories that both contain `cn=John Smith`, imported into the
|
||||||
|
* *same* address book, are one contact afterwards. Matching is per book, so
|
||||||
|
* filing two directories in two books keeps them apart.
|
||||||
|
*
|
||||||
|
* Normalised for case and for the spacing exporters differ in, which costs
|
||||||
|
* nothing when a file is compared against itself and helps when it is compared
|
||||||
|
* against a differently-produced export of the same directory.
|
||||||
|
*
|
||||||
|
* Null for an entry with no usable `dn`: that entry gets an identity of its own
|
||||||
|
* and duplicates on re-import, as everything did before.
|
||||||
|
*/
|
||||||
|
export function uidFromDn(dn: string): string | null {
|
||||||
|
const normalised = dn
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.replace(/\s*([,=])\s*/g, "$1");
|
||||||
|
return normalised ? `urn:x-ihasmail:ldif:${encodeURIComponent(normalised)}` : null;
|
||||||
|
}
|
||||||
|
|||||||
+25
-1
@@ -8,9 +8,33 @@ export function setBaseTitle(t: string) {
|
|||||||
baseTitle = t;
|
baseTitle = t;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update document title and favicon badge with unread count. */
|
/*
|
||||||
|
* The unread count on the installed app's icon.
|
||||||
|
*
|
||||||
|
* The title and the favicon below are the same idea for a tab, and an
|
||||||
|
* installed app has neither: in `display: standalone` there is no tab strip
|
||||||
|
* and no favicon anywhere on screen, so everything this file did for the
|
||||||
|
* unread count vanished at exactly the moment somebody put ihasmail on a home
|
||||||
|
* screen. The Badging API is where the count goes instead, and it is the one
|
||||||
|
* thing every phone user expects a mail icon to do.
|
||||||
|
*
|
||||||
|
* Silently nothing where it is unsupported, and silently nothing on iOS until
|
||||||
|
* notification permission has been granted, which is that platform's condition
|
||||||
|
* for showing a badge at all. Neither is worth reporting: a count that does not
|
||||||
|
* appear is not a failure anybody can act on.
|
||||||
|
*/
|
||||||
|
function setIconBadge(count: number): void {
|
||||||
|
if (!("setAppBadge" in navigator)) return;
|
||||||
|
const done = count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge();
|
||||||
|
void done.catch(() => {
|
||||||
|
/* unsupported, or not permitted on this platform */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update document title, favicon and app icon badge with unread count. */
|
||||||
export function setUnreadBadge(count: number): void {
|
export function setUnreadBadge(count: number): void {
|
||||||
document.title = count > 0 ? `(${count > 999 ? "999+" : count}) ${baseTitle}` : baseTitle;
|
document.title = count > 0 ? `(${count > 999 ? "999+" : count}) ${baseTitle}` : baseTitle;
|
||||||
|
setIconBadge(count);
|
||||||
try {
|
try {
|
||||||
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"][type="image/png"]');
|
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"][type="image/png"]');
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* What "open" means when conversation view is off.
|
||||||
|
*
|
||||||
|
* The setting used to reach only as far as the query -- it set `collapseThreads`
|
||||||
|
* and nothing else -- so the list showed individual messages while everything
|
||||||
|
* downstream still worked in threads. Opening one message highlighted every row
|
||||||
|
* in its thread and filled the reading pane with the whole conversation, which
|
||||||
|
* is exactly the grouping the setting was turned off to avoid.
|
||||||
|
*
|
||||||
|
* Both halves are the same question asked in two places, so they live together.
|
||||||
|
*/
|
||||||
|
import type { Id } from "@/jmap/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a list row should be drawn as the open one.
|
||||||
|
*
|
||||||
|
* With a message singled out the row must match it exactly. Matching on the
|
||||||
|
* thread is what lit up every sibling.
|
||||||
|
*/
|
||||||
|
export function rowIsOpen(rowId: Id, rowThreadId: Id | undefined, openMessageId: Id | null, openThreadId: Id | null): boolean {
|
||||||
|
if (openMessageId) return rowId === openMessageId;
|
||||||
|
return Boolean(openThreadId) && rowThreadId === openThreadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The messages the reading pane should render.
|
||||||
|
*
|
||||||
|
* Falls back to the whole thread when the id names nothing in it. That is what
|
||||||
|
* a link from somebody with conversation view *on* looks like, and what a
|
||||||
|
* lingering `m` parameter looks like after the setting is switched back -- a
|
||||||
|
* conversation is a better answer to both than an empty pane.
|
||||||
|
*/
|
||||||
|
export function visibleMessages<T extends { id: Id }>(messages: T[], openMessageId: Id | null): T[] {
|
||||||
|
if (!openMessageId) return messages;
|
||||||
|
const single = messages.filter((m) => m.id === openMessageId);
|
||||||
|
return single.length ? single : messages;
|
||||||
|
}
|
||||||
+16
-6
@@ -13,7 +13,9 @@
|
|||||||
* the derivation can be checked rather than taken on trust.
|
* the derivation can be checked rather than taken on trust.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type PaletteId = "default" | "ihasmail" | "dracula" | "gruvbox" | "rose-pine" | "tokyo-night";
|
export type PaletteId =
|
||||||
|
| "default" | "ihasmail" | "dracula" | "gruvbox" | "rose-pine" | "tokyo-night"
|
||||||
|
| "catppuccin" | "solarized" | "ayu" | "kanagawa" | "everforest" | "primer";
|
||||||
export type Mode = "system" | "light" | "dark";
|
export type Mode = "system" | "light" | "dark";
|
||||||
/** What a mode resolves to once the system has been asked. */
|
/** What a mode resolves to once the system has been asked. */
|
||||||
export type ResolvedMode = "light" | "dark";
|
export type ResolvedMode = "light" | "dark";
|
||||||
@@ -26,11 +28,11 @@ export interface PaletteMeta {
|
|||||||
/**
|
/**
|
||||||
* Whether the name is a word rather than a name.
|
* Whether the name is a word rather than a name.
|
||||||
*
|
*
|
||||||
* Five of these six are proper names -- ihasmail, Dracula, Gruvbox, Rosé
|
* All but one of these are proper names -- ihasmail, Dracula, Gruvbox and
|
||||||
* Pine, Tokyo Night -- and are rendered translate="no" so a page translator
|
* the rest -- and are rendered translate="no" so a page translator leaves
|
||||||
* leaves them alone. "Classic" is not a name, it is an adjective describing
|
* them alone. "Classic" is not a name, it is an adjective describing the
|
||||||
* the theme, and a German reader should see "Klassisch". Reported by a
|
* theme, and a German reader should see "Klassisch". Reported by a native
|
||||||
* native speaker reviewing the German catalogue (#247).
|
* speaker reviewing the German catalogue (#247).
|
||||||
*/
|
*/
|
||||||
translatable?: boolean;
|
translatable?: boolean;
|
||||||
}
|
}
|
||||||
@@ -42,6 +44,14 @@ export const PALETTES: PaletteMeta[] = [
|
|||||||
{ id: "gruvbox", name: "Gruvbox", credit: "gruvbox by morhetz (MIT)" },
|
{ id: "gruvbox", name: "Gruvbox", credit: "gruvbox by morhetz (MIT)" },
|
||||||
{ id: "rose-pine", name: "Rosé Pine", credit: "Rosé Pine (MIT) — light variant is Dawn" },
|
{ id: "rose-pine", name: "Rosé Pine", credit: "Rosé Pine (MIT) — light variant is Dawn" },
|
||||||
{ id: "tokyo-night", name: "Tokyo Night", credit: "Tokyo Night by enkia (MIT) — light variant is Day" },
|
{ id: "tokyo-night", name: "Tokyo Night", credit: "Tokyo Night by enkia (MIT) — light variant is Day" },
|
||||||
|
{ id: "catppuccin", name: "Catppuccin", credit: "Catppuccin (MIT) — dark is Mocha, light is Latte" },
|
||||||
|
{ id: "solarized", name: "Solarized", credit: "Solarized by Ethan Schoonover (MIT) — light and dark are both original" },
|
||||||
|
{ id: "ayu", name: "Ayu", credit: "Ayu by Konstantin Pschera (MIT)" },
|
||||||
|
{ id: "kanagawa", name: "Kanagawa", credit: "Kanagawa by rebelot (MIT) — dark is Wave, light is Lotus" },
|
||||||
|
{ id: "everforest", name: "Everforest", credit: "Everforest by sainnhe (MIT)" },
|
||||||
|
// Named for the design system rather than for GitHub: the colours are MIT,
|
||||||
|
// the name and the logo are trademarks, and nothing here is endorsed.
|
||||||
|
{ id: "primer", name: "Primer", credit: "GitHub's Primer primitives (MIT); not affiliated with or endorsed by GitHub" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const byId = new Map(PALETTES.map((p) => [p.id, p]));
|
const byId = new Map(PALETTES.map((p) => [p.id, p]));
|
||||||
|
|||||||
+101
-31
@@ -1,14 +1,25 @@
|
|||||||
import type { JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
import type { JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
||||||
|
import { formatList, weekdayName, weekdayNames } from "./datetime";
|
||||||
|
import { plural, t } from "@/lib/i18n";
|
||||||
|
|
||||||
export const WEEKDAYS: Array<{ key: JSCalendarNDay["day"]; label: string; short: string }> = [
|
/**
|
||||||
{ key: "mo", label: "Monday", short: "M" },
|
* The seven days, Monday first, named in the reader's locale.
|
||||||
{ key: "tu", label: "Tuesday", short: "T" },
|
*
|
||||||
{ key: "we", label: "Wednesday", short: "W" },
|
* This was a table of English strings carrying `label: "Monday"` and
|
||||||
{ key: "th", label: "Thursday", short: "T" },
|
* `short: "M"`, rendered straight into the picker. The long names could have
|
||||||
{ key: "fr", label: "Friday", short: "F" },
|
* become catalogue entries; the short ones could not, because "T" is both
|
||||||
{ key: "sa", label: "Saturday", short: "S" },
|
* Tuesday and Thursday and "S" is both Saturday and Sunday, and a catalogue
|
||||||
{ key: "su", label: "Sunday", short: "S" },
|
* cannot hold two translations under one key. Intl knows all of them.
|
||||||
];
|
*/
|
||||||
|
export const WEEKDAY_KEYS: Array<JSCalendarNDay["day"]> = ["mo", "tu", "we", "th", "fr", "sa", "su"];
|
||||||
|
|
||||||
|
export function weekdayOptions(): Array<{ key: JSCalendarNDay["day"]; label: string; short: string }> {
|
||||||
|
return weekdayNames("long").map(({ key, name }) => ({
|
||||||
|
key: key as JSCalendarNDay["day"],
|
||||||
|
label: name,
|
||||||
|
short: weekdayName(key, "narrow"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export type RecurrencePreset = "none" | "daily" | "weekly" | "weekdays" | "monthly" | "yearly" | "custom";
|
export type RecurrencePreset = "none" | "daily" | "weekly" | "weekdays" | "monthly" | "yearly" | "custom";
|
||||||
|
|
||||||
@@ -28,7 +39,7 @@ export function presetFor(rule: JSCalendarRecurrenceRule | undefined): Recurrenc
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ruleFromPreset(preset: RecurrencePreset, start: Date): JSCalendarRecurrenceRule | undefined {
|
export function ruleFromPreset(preset: RecurrencePreset, start: Date): JSCalendarRecurrenceRule | undefined {
|
||||||
const dow = WEEKDAYS[(start.getDay() + 6) % 7]!.key;
|
const dow = WEEKDAY_KEYS[(start.getDay() + 6) % 7]!;
|
||||||
switch (preset) {
|
switch (preset) {
|
||||||
case "daily":
|
case "daily":
|
||||||
return { "@type": "RecurrenceRule", frequency: "daily" };
|
return { "@type": "RecurrenceRule", frequency: "daily" };
|
||||||
@@ -45,48 +56,107 @@ export function ruleFromPreset(preset: RecurrencePreset, start: Date): JSCalenda
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recurrence rule as a sentence.
|
||||||
|
*
|
||||||
|
* Built as whole sentences with placeholders rather than by concatenation.
|
||||||
|
* The old version appended fragments -- `base += " on " + names` -- which is
|
||||||
|
* untranslatable however complete the catalogue is: German puts the weekday
|
||||||
|
* list somewhere else in the clause, and a translator handed " on " alone
|
||||||
|
* cannot move it. Every branch below is one key a translator can rewrite in
|
||||||
|
* full, including the word order.
|
||||||
|
*/
|
||||||
export function describeRule(rule: JSCalendarRecurrenceRule | undefined): string {
|
export function describeRule(rule: JSCalendarRecurrenceRule | undefined): string {
|
||||||
if (!rule) return "Does not repeat";
|
if (!rule) return t("Does not repeat");
|
||||||
const n = rule.interval ?? 1;
|
const n = rule.interval ?? 1;
|
||||||
|
const every = n !== 1;
|
||||||
let base: string;
|
let base: string;
|
||||||
|
|
||||||
switch (rule.frequency) {
|
switch (rule.frequency) {
|
||||||
case "daily":
|
case "daily":
|
||||||
base = n === 1 ? "Daily" : `Every ${n} days`;
|
base = every ? plural(n, { one: "Every {n} day", other: "Every {n} days" }) : t("Daily");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "weekly": {
|
case "weekly": {
|
||||||
base = n === 1 ? "Weekly" : `Every ${n} weeks`;
|
const days = rule.byDay?.length ? rule.byDay.map((d) => d.day) : [];
|
||||||
if (rule.byDay?.length) {
|
const weekdaysOnly =
|
||||||
const names = rule.byDay.map((d) => WEEKDAYS.find((w) => w.key === d.day)?.label ?? d.day);
|
days.length === 5 && ["mo", "tu", "we", "th", "fr"].every((d) => days.includes(d as JSCalendarNDay["day"]));
|
||||||
const set = rule.byDay.map((d) => d.day).sort().join(",");
|
if (weekdaysOnly && !every) {
|
||||||
if (set === ["mo", "tu", "we", "th", "fr"].sort().join(",") && n === 1) base = "Every weekday";
|
base = t("Every weekday");
|
||||||
else base += ` on ${names.join(", ")}`;
|
} else if (days.length) {
|
||||||
|
const list = formatList(days.map((d) => weekdayName(d as never)));
|
||||||
|
base = every
|
||||||
|
? plural(n, { one: "Every {n} week on {days}", other: "Every {n} weeks on {days}" }, { days: list })
|
||||||
|
: t("Weekly on {days}", { days: list });
|
||||||
|
} else {
|
||||||
|
base = every ? plural(n, { one: "Every {n} week", other: "Every {n} weeks" }) : t("Weekly");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "monthly": {
|
case "monthly": {
|
||||||
base = n === 1 ? "Monthly" : `Every ${n} months`;
|
if (rule.byMonthDay?.length) {
|
||||||
if (rule.byMonthDay?.length) base += ` on day ${rule.byMonthDay.join(", ")}`;
|
const list = formatList(rule.byMonthDay.map(String));
|
||||||
else if (rule.byDay?.length) {
|
base = every
|
||||||
|
? plural(n, { one: "Every {n} month on day {days}", other: "Every {n} months on day {days}" }, { days: list })
|
||||||
|
: t("Monthly on day {days}", { days: list });
|
||||||
|
} else if (rule.byDay?.length) {
|
||||||
const d = rule.byDay[0]!;
|
const d = rule.byDay[0]!;
|
||||||
const ord = d.nthOfPeriod ? ordinal(d.nthOfPeriod) + " " : "";
|
const weekday = weekdayName(d.day as never);
|
||||||
base += ` on the ${ord}${WEEKDAYS.find((w) => w.key === d.day)?.label ?? d.day}`;
|
if (d.nthOfPeriod) {
|
||||||
|
const ord = ordinal(d.nthOfPeriod);
|
||||||
|
base = every
|
||||||
|
? plural(n, { one: "Every {n} month on the {ordinal} {weekday}", other: "Every {n} months on the {ordinal} {weekday}" }, { ordinal: ord, weekday })
|
||||||
|
: t("Monthly on the {ordinal} {weekday}", { ordinal: ord, weekday });
|
||||||
|
} else {
|
||||||
|
base = every
|
||||||
|
? plural(n, { one: "Every {n} month on {weekday}", other: "Every {n} months on {weekday}" }, { weekday })
|
||||||
|
: t("Monthly on {weekday}", { weekday });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
base = every ? plural(n, { one: "Every {n} month", other: "Every {n} months" }) : t("Monthly");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "yearly":
|
case "yearly":
|
||||||
base = n === 1 ? "Yearly" : `Every ${n} years`;
|
base = every ? plural(n, { one: "Every {n} year", other: "Every {n} years" }) : t("Yearly");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
base = `Every ${n} ${rule.frequency}`;
|
// An RFC frequency this build has no sentence for. The frequency word
|
||||||
|
// itself stays as the server sent it rather than being invented.
|
||||||
|
base = t("Every {n} {frequency}", { n, frequency: rule.frequency });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tail wraps the sentence rather than being glued to its end, so a
|
||||||
|
// translator can put "until 3 May" first if that is what the language does.
|
||||||
|
if (rule.count) {
|
||||||
|
base = plural(rule.count, { one: "{rule}, {n} time", other: "{rule}, {n} times" }, { rule: base });
|
||||||
|
}
|
||||||
|
if (rule.until) {
|
||||||
|
base = t("{rule}, until {date}", { rule: base, date: rule.until.slice(0, 10) });
|
||||||
}
|
}
|
||||||
if (rule.count) base += `, ${rule.count} times`;
|
|
||||||
if (rule.until) base += `, until ${rule.until.slice(0, 10)}`;
|
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "first", "second", "last" -- words, not "1st".
|
||||||
|
*
|
||||||
|
* The suffix table this replaced ("st", "nd", "rd", "th") is English spelling
|
||||||
|
* rules in code: German writes "1.", Japanese "第1", and no catalogue can
|
||||||
|
* reach a suffix chosen by arithmetic. JSCalendar's nthOfPeriod is 1-5 or -1
|
||||||
|
* in practice, so five words and "last" cover it; anything else falls back to
|
||||||
|
* the bare number, which is wrong in no language.
|
||||||
|
*/
|
||||||
function ordinal(n: number): string {
|
function ordinal(n: number): string {
|
||||||
if (n === -1) return "last";
|
switch (n) {
|
||||||
const s = ["th", "st", "nd", "rd"];
|
case -1: return t("last");
|
||||||
const v = n % 100;
|
case 1: return t("first");
|
||||||
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]!);
|
case 2: return t("second");
|
||||||
|
case 3: return t("third");
|
||||||
|
case 4: return t("fourth");
|
||||||
|
case 5: return t("fifth");
|
||||||
|
default: return String(n);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/*
|
||||||
|
* The operating system's own share sheet.
|
||||||
|
*
|
||||||
|
* Everything that leaves ihasmail today leaves as a download, and on a phone a
|
||||||
|
* download is close to a dead end: the file lands in Downloads and the person
|
||||||
|
* who wanted to send it somewhere goes hunting for it in a file manager. Web
|
||||||
|
* Share hands the bytes straight to whatever they meant to send them to, which
|
||||||
|
* is the thing they were actually trying to do.
|
||||||
|
*
|
||||||
|
* Every entry point feature-detects and disappears where the API is not there
|
||||||
|
* rather than failing at the tap: `navigator.share` is absent on desktop Linux
|
||||||
|
* and in Firefox, exists on iOS and Android and on Windows and macOS Chrome,
|
||||||
|
* and file sharing is a separate question from sharing at all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What became of a share.
|
||||||
|
*
|
||||||
|
* `unsupported` is the interesting one: it says the share did not happen and
|
||||||
|
* the caller should do whatever it did before — for an attachment, download
|
||||||
|
* it. It covers both "this browser cannot" and "this browser could not this
|
||||||
|
* time", because to the caller those are the same instruction.
|
||||||
|
*/
|
||||||
|
export type ShareOutcome = "shared" | "dismissed" | "unsupported";
|
||||||
|
|
||||||
|
/** Whether the browser can share at all. */
|
||||||
|
export function canShare(): boolean {
|
||||||
|
return typeof navigator !== "undefined" && typeof navigator.share === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Whether it can share *files*, asked once and remembered.
|
||||||
|
*
|
||||||
|
* `canShare()` needs a real File to answer, and the answer is about the
|
||||||
|
* browser rather than about any particular file, so a one-byte probe settles
|
||||||
|
* it for the session. It has to be asked before there is anything to share:
|
||||||
|
* this is what decides whether a Share button is drawn at all, and drawing one
|
||||||
|
* that turns out to be a download in disguise is worse than not drawing it.
|
||||||
|
*
|
||||||
|
* A byte rather than an empty file on purpose — an implementation is entitled
|
||||||
|
* to refuse a zero-length one, and being told "no" by the probe would hide the
|
||||||
|
* button everywhere.
|
||||||
|
*/
|
||||||
|
let fileShareSupported: boolean | null = null;
|
||||||
|
export function canShareFiles(): boolean {
|
||||||
|
if (fileShareSupported === null) {
|
||||||
|
try {
|
||||||
|
fileShareSupported =
|
||||||
|
canShare() &&
|
||||||
|
typeof navigator.canShare === "function" &&
|
||||||
|
navigator.canShare({ files: [new File(["x"], "probe.txt", { type: "text/plain" })] });
|
||||||
|
} catch {
|
||||||
|
fileShareSupported = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fileShareSupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the remembered probe. Tests only. */
|
||||||
|
export function resetShareSupport(): void {
|
||||||
|
fileShareSupported = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Share text, a title, a URL, or any combination the browser accepts. */
|
||||||
|
export async function shareText(data: { title?: string; text?: string; url?: string }): Promise<ShareOutcome> {
|
||||||
|
if (!canShare()) return "unsupported";
|
||||||
|
return await run(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share one file. `unsupported` means nothing happened and the caller should
|
||||||
|
* fall back to a download.
|
||||||
|
*/
|
||||||
|
export async function shareFile(file: File, extra: { title?: string; text?: string } = {}): Promise<ShareOutcome> {
|
||||||
|
if (!canShare() || !navigator.canShare?.({ files: [file] })) return "unsupported";
|
||||||
|
return await run({ ...extra, files: [file] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(data: ShareData): Promise<ShareOutcome> {
|
||||||
|
try {
|
||||||
|
await navigator.share(data);
|
||||||
|
return "shared";
|
||||||
|
} catch (err) {
|
||||||
|
const name = err instanceof DOMException ? err.name : "";
|
||||||
|
// The sheet opened and was closed again. That is a decision, not a fault,
|
||||||
|
// and a toast for it would be scolding somebody for changing their mind.
|
||||||
|
if (name === "AbortError") return "dismissed";
|
||||||
|
/*
|
||||||
|
* `NotAllowedError` is reported as unsupported rather than raised, because
|
||||||
|
* what it nearly always means here is that the tap's transient activation
|
||||||
|
* ran out while the attachment downloaded. `share()` takes files and not a
|
||||||
|
* promise of them, so there is no way to open the sheet first and fill it
|
||||||
|
* afterwards — the fetch has to happen inside the gesture's window, and on
|
||||||
|
* a slow connection and a large attachment it will sometimes not fit.
|
||||||
|
*
|
||||||
|
* The caller's fallback is a download, which is exactly what the button
|
||||||
|
* did before this existed, so the failure costs a tap rather than the file.
|
||||||
|
*/
|
||||||
|
if (name === "NotAllowedError") return "unsupported";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/*
|
||||||
|
* Collecting a share the operating system sent us.
|
||||||
|
*
|
||||||
|
* The other end of `share_target` in the manifest: the system POSTs a form at
|
||||||
|
* `<base>/share`, the service worker takes the body and stashes it, and this
|
||||||
|
* is the tab picking it up. See the note on `stashShare` in sw.js for why the
|
||||||
|
* worker answers that request rather than the app or the server.
|
||||||
|
*
|
||||||
|
* The handoff goes through the cache rather than postMessage because a share
|
||||||
|
* usually launches the app: there is no tab to message at the moment it
|
||||||
|
* arrives, and the one that appears a second later is a different context that
|
||||||
|
* has to find the payload lying somewhere.
|
||||||
|
*/
|
||||||
|
import { withBase } from "./basePath";
|
||||||
|
import { SW_CACHE_NAME } from "./swCache";
|
||||||
|
|
||||||
|
export interface SharedContent {
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
url: string;
|
||||||
|
files: File[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The worker writes here; both sides name it absolutely. */
|
||||||
|
const SHARE_KEY = "/ihasmail-share";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* How long a share is worth acting on.
|
||||||
|
*
|
||||||
|
* It is collected on every app start rather than only when the launch URL says
|
||||||
|
* so, because the launch may not survive the trip: a share to a signed-out
|
||||||
|
* ihasmail lands on the sign-in page, and the composer can only open once
|
||||||
|
* there is an account to open it in. Waiting for that means the payload has to
|
||||||
|
* outlive a redirect and a login, which the query string does not.
|
||||||
|
*
|
||||||
|
* What that costs is the possibility of a stash nobody ever came back for, so
|
||||||
|
* it expires. Ten minutes is long enough for signing in -- password manager,
|
||||||
|
* app password, a second device -- and short enough that a share abandoned
|
||||||
|
* this morning does not open a composer full of a forgotten photo tonight.
|
||||||
|
*/
|
||||||
|
export const SHARE_MAX_AGE_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
interface StashedFile {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take whatever the worker left, and leave nothing behind.
|
||||||
|
*
|
||||||
|
* Returns null when there is nothing waiting, which is almost every start.
|
||||||
|
* The entries are deleted whether or not the share is still worth opening: a
|
||||||
|
* stash that stayed would be collected on the next start instead, which is the
|
||||||
|
* expiry doing nothing.
|
||||||
|
*/
|
||||||
|
export async function collectShare(): Promise<SharedContent | null> {
|
||||||
|
if (typeof caches === "undefined") return null;
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(SW_CACHE_NAME);
|
||||||
|
const key = withBase(SHARE_KEY);
|
||||||
|
const hit = await cache.match(key);
|
||||||
|
if (!hit) return null;
|
||||||
|
|
||||||
|
const meta = (await hit.json()) as Partial<SharedContent> & { at?: number; files?: StashedFile[] };
|
||||||
|
await cache.delete(key);
|
||||||
|
|
||||||
|
const files: File[] = [];
|
||||||
|
for (const f of meta.files ?? []) {
|
||||||
|
const res = await cache.match(f.key);
|
||||||
|
await cache.delete(f.key);
|
||||||
|
if (!res) continue;
|
||||||
|
/*
|
||||||
|
* The bytes, rather than the Blob holding them.
|
||||||
|
*
|
||||||
|
* `new File([blob], …)` is correct and works in a browser, but a Blob
|
||||||
|
* only counts as a part where the File constructor recognises it as one
|
||||||
|
* -- and where it does not, it is stringified instead, producing a file
|
||||||
|
* containing the thirteen characters "[object Blob]" and no error
|
||||||
|
* anywhere. That is exactly what CI caught on Node 22 while it passed
|
||||||
|
* here on 26. An ArrayBuffer is a part on any implementation, and this
|
||||||
|
* has the whole file in memory a moment later regardless: it is about to
|
||||||
|
* be uploaded as an attachment.
|
||||||
|
*/
|
||||||
|
files.push(new File([await res.arrayBuffer()], f.name, { type: f.type }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof meta.at === "number" && Date.now() - meta.at > SHARE_MAX_AGE_MS) return null;
|
||||||
|
|
||||||
|
const share: SharedContent = {
|
||||||
|
title: meta.title ?? "",
|
||||||
|
text: meta.text ?? "",
|
||||||
|
url: meta.url ?? "",
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
// A share with nothing in it is a share that went wrong upstream. Opening
|
||||||
|
// an empty composer over the inbox would be a worse account of that than
|
||||||
|
// opening nothing.
|
||||||
|
return share.title || share.text || share.url || files.length ? share : null;
|
||||||
|
} catch {
|
||||||
|
/* no cache, or nothing waiting: not a failure */
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared text and the shared link as one body.
|
||||||
|
*
|
||||||
|
* What arrives in which field is up to whatever did the sharing, and they do
|
||||||
|
* not agree: a link from Chrome comes as a title and a `url`, from other apps
|
||||||
|
* as `text` that already *is* the link, and from a few as both. Appending it
|
||||||
|
* unconditionally would put the same URL in twice as often as not.
|
||||||
|
*/
|
||||||
|
export function shareBody(share: Pick<SharedContent, "text" | "url">): string {
|
||||||
|
const text = share.text.trim();
|
||||||
|
const url = share.url.trim();
|
||||||
|
if (!url || text.includes(url)) return text;
|
||||||
|
return text ? `${text}\n\n${url}` : url;
|
||||||
|
}
|
||||||
+49
-35
@@ -6,6 +6,9 @@
|
|||||||
* Sieve below each comment is what the server actually runs.
|
* Sieve below each comment is what the server actually runs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { formatList } from "./datetime";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export type HeaderOp = "contains" | "notcontains" | "is" | "notis" | "matches" | "notmatches" | "regex" | "notregex" | "exists" | "notexists";
|
export type HeaderOp = "contains" | "notcontains" | "is" | "notis" | "matches" | "notmatches" | "regex" | "notregex" | "exists" | "notexists";
|
||||||
|
|
||||||
export type SieveTest =
|
export type SieveTest =
|
||||||
@@ -318,49 +321,60 @@ export function reorderRules(rules: SieveRule[], fromId: string, toId: string, b
|
|||||||
return [...rest.slice(0, at), moved, ...rest.slice(at)];
|
return [...rest.slice(0, at), moved, ...rest.slice(at)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A filter rule as a sentence, for the rule list.
|
||||||
|
*
|
||||||
|
* Rebuilt as whole sentences with placeholders. The old version concatenated
|
||||||
|
* fragments -- a header name, an operator, a quoted value, joined by " and "
|
||||||
|
* -- which no catalogue could fix: German puts the verb last, Japanese does
|
||||||
|
* not separate list items with a word at all, and a translator handed " and "
|
||||||
|
* on its own cannot move anything. Reported by a native speaker reviewing the
|
||||||
|
* German catalogue (#247).
|
||||||
|
*
|
||||||
|
* Intl.ListFormat does the joining, so "A, B and C" becomes "A, B und C" and,
|
||||||
|
* for an anyof rule, the disjunction the language actually uses.
|
||||||
|
*/
|
||||||
export function describeRule(r: SieveRule): string {
|
export function describeRule(r: SieveRule): string {
|
||||||
const tests = r.tests
|
const headerLabel = (h: string): string => t(HEADER_CHOICES.find((c) => c.value === h)?.label ?? h);
|
||||||
.map((t) => {
|
const opLabel = (op: string): string => t(HEADER_OPS.find((o) => o.value === op)?.label ?? op);
|
||||||
switch (t.type) {
|
|
||||||
|
const tests = r.tests.map((test) => {
|
||||||
|
switch (test.type) {
|
||||||
case "header":
|
case "header":
|
||||||
return `${t.header} ${HEADER_OPS.find((o) => o.value === t.op)?.label ?? t.op} "${t.value}"`;
|
return t('{header} {op} "{value}"', { header: headerLabel(test.header), op: opLabel(test.op), value: test.value });
|
||||||
case "address":
|
case "address":
|
||||||
return `${t.header} address ${HEADER_OPS.find((o) => o.value === t.op)?.label ?? t.op} "${t.value}"`;
|
return t('{header} address {op} "{value}"', { header: headerLabel(test.header), op: opLabel(test.op), value: test.value });
|
||||||
case "size":
|
case "size":
|
||||||
return `size ${t.op} ${Math.round(t.value / 1024)} KB`;
|
return test.op === "over"
|
||||||
|
? t("size is over {n} KB", { n: Math.round(test.value / 1024) })
|
||||||
|
: t("size is under {n} KB", { n: Math.round(test.value / 1024) });
|
||||||
case "body":
|
case "body":
|
||||||
return `body ${t.op === "contains" ? "contains" : "does not contain"} "${t.value}"`;
|
return test.op === "contains"
|
||||||
|
? t('body contains "{value}"', { value: test.value })
|
||||||
|
: t('body does not contain "{value}"', { value: test.value });
|
||||||
case "true":
|
case "true":
|
||||||
return "always";
|
return t("always");
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
.join(r.join === "allof" ? " and " : " or ");
|
|
||||||
const actions = r.actions
|
const actions = r.actions.map((a) => {
|
||||||
.map((a) => {
|
|
||||||
switch (a.type) {
|
switch (a.type) {
|
||||||
case "fileinto":
|
case "fileinto": return t("move to {folder}", { folder: a.mailbox });
|
||||||
return `move to ${a.mailbox}`;
|
case "redirect": return t("forward to {address}", { address: a.address });
|
||||||
case "redirect":
|
case "discard": return t("delete it");
|
||||||
return `forward to ${a.address}`;
|
case "keep": return t("keep it");
|
||||||
case "discard":
|
case "reject": return t("reject it");
|
||||||
return "delete";
|
case "markread": return t("mark it read");
|
||||||
case "keep":
|
case "flag": return t("star it");
|
||||||
return "keep";
|
|
||||||
case "reject":
|
|
||||||
return "reject";
|
|
||||||
case "markread":
|
|
||||||
return "mark read";
|
|
||||||
case "flag":
|
|
||||||
return "star";
|
|
||||||
case "addflag":
|
case "addflag":
|
||||||
case "setflag":
|
case "setflag": return t("add {flag}", { flag: a.flag });
|
||||||
return `add ${a.flag}`;
|
case "removeflag": return t("remove {flag}", { flag: a.flag });
|
||||||
case "removeflag":
|
case "stop": return t("stop");
|
||||||
return `remove ${a.flag}`;
|
|
||||||
case "stop":
|
|
||||||
return "stop";
|
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
.join(", ");
|
|
||||||
return `${tests || "always"} → ${actions}`;
|
return t("{tests} → {actions}", {
|
||||||
|
tests: tests.length ? formatList(tests, r.join === "allof" ? "conjunction" : "disjunction") : t("always"),
|
||||||
|
actions: formatList(actions, "conjunction"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
To: [email protected]
|
||||||
|
From: Grace Hopper <[email protected]>
|
||||||
|
Subject: A note
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----1F14F13899656314019C2DF4C8728ED3"
|
||||||
|
|
||||||
|
This is an S/MIME signed message
|
||||||
|
|
||||||
|
------1F14F13899656314019C2DF4C8728ED3
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
The Analytical Engine has no pretensions whatever to originate anything.
|
||||||
|
|
||||||
|
------1F14F13899656314019C2DF4C8728ED3
|
||||||
|
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
Content-Disposition: attachment; filename="smime.p7s"
|
||||||
|
|
||||||
|
MIIDtQYJKoZIhvcNAQcCoIIDpjCCA6ICAQExDzANBglghkgBZQMEAgEFADALBgkq
|
||||||
|
hkiG9w0BBwGgggHjMIIB3zCCAYagAwIBAgIUejcvY0YWeoAxAMTf4bp3jOkMCVww
|
||||||
|
CgYIKoZIzj0EAwIwKzEVMBMGA1UEAwwMR3JhY2UgSG9wcGVyMRIwEAYDVQQKDAlD
|
||||||
|
b21waWxlcnMwHhcNMjYwOTA1MDgyNzQ4WhcNMzYwOTAyMDgyNzQ4WjArMRUwEwYD
|
||||||
|
VQQDDAxHcmFjZSBIb3BwZXIxEjAQBgNVBAoMCUNvbXBpbGVyczBZMBMGByqGSM49
|
||||||
|
AgEGCCqGSM49AwEHA0IABOcBiRNb2IF0vlQJlnVKJXFr0taIz2LoU+8p0GQZ9kas
|
||||||
|
DnlHQaCR17JtDfGA6azi7w2ZOYZg0ZjGxbcKoTWKdR6jgYcwgYQwHQYDVR0OBBYE
|
||||||
|
FGqS/6/0hXUay/WvkZrl3DCTY1sTMB8GA1UdIwQYMBaAFGqS/6/0hXUay/WvkZrl
|
||||||
|
3DCTY1sTMA8GA1UdEwEB/wQFMAMBAf8wHAYDVR0RBBUwE4ERZ3JhY2VAZXhhbXBs
|
||||||
|
ZS5jb20wEwYDVR0lBAwwCgYIKwYBBQUHAwQwCgYIKoZIzj0EAwIDRwAwRAIgbJ+R
|
||||||
|
K6iGkQ6qrzT7m7E09D2rW+O3/LYoRPlwo5EJiZkCIHAvYgUyxqeRGNr/adeVmqvv
|
||||||
|
borP2nkAD8LjCUdtv1+NMYIBljCCAZICAQEwQzArMRUwEwYDVQQDDAxHcmFjZSBI
|
||||||
|
b3BwZXIxEjAQBgNVBAoMCUNvbXBpbGVycwIUejcvY0YWeoAxAMTf4bp3jOkMCVww
|
||||||
|
DQYJYIZIAWUDBAIBBQCggeQwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAcBgkq
|
||||||
|
hkiG9w0BCQUxDxcNMjYwOTA1MDgyNzQ4WjAvBgkqhkiG9w0BCQQxIgQgD6tNEuEW
|
||||||
|
5UlYvan88jdbR0HwFJnJxh0YtHUGA9NIyf8weQYJKoZIhvcNAQkPMWwwajALBglg
|
||||||
|
hkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQBAjAKBggqhkiG9w0DBzAO
|
||||||
|
BggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYIKoZIhvcN
|
||||||
|
AwICASgwCgYIKoZIzj0EAwIERjBEAiBR36dlKMTvZKbufvpVZNylX7yxQrra+xHP
|
||||||
|
+KnbTB8rzAIgIW0ArgkEKCYF8eF76KyjOcfAlXT1MJKjiCjYj9I13Ug=
|
||||||
|
|
||||||
|
------1F14F13899656314019C2DF4C8728ED3--
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
To: [email protected]
|
||||||
|
From: Ada Lovelace <[email protected]>
|
||||||
|
Subject: A note
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----12D00EBC0B5D3352F0E2F25B1A5D3552"
|
||||||
|
|
||||||
|
This is an S/MIME signed message
|
||||||
|
|
||||||
|
------12D00EBC0B5D3352F0E2F25B1A5D3552
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
The Analytical Engine has no pretensions whatever to originate anything.
|
||||||
|
|
||||||
|
------12D00EBC0B5D3352F0E2F25B1A5D3552
|
||||||
|
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
Content-Disposition: attachment; filename="smime.p7s"
|
||||||
|
|
||||||
|
MIIGJwYJKoZIhvcNAQcCoIIGGDCCBhQCAQExDzANBglghkgBZQMEAgEFADALBgkq
|
||||||
|
hkiG9w0BBwGgggONMIIDiTCCAnGgAwIBAgIUPg49mvsUaCFoIGXWVrE9r5aFVmMw
|
||||||
|
DQYJKoZIhvcNAQELBQAwNDEVMBMGA1UEAwwMQWRhIExvdmVsYWNlMRswGQYDVQQK
|
||||||
|
DBJBbmFseXRpY2FsIEVuZ2luZXMwHhcNMjYwOTA1MDgyNzQ4WhcNMzYwOTAyMDgy
|
||||||
|
NzQ4WjA0MRUwEwYDVQQDDAxBZGEgTG92ZWxhY2UxGzAZBgNVBAoMEkFuYWx5dGlj
|
||||||
|
YWwgRW5naW5lczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJSBFbrB
|
||||||
|
kSLTrHoufW5WNYoHfPQYd+keeSsZnhl8NGcTUioXLFPgX+ueolebMBRvSQ+eFnXV
|
||||||
|
cYgDtu4ye5qfyYL3WvCWQoiwgu2nP8z6kFTiQKlu2ZRCYsm/0+DSD28wHQFx+9Ns
|
||||||
|
lLYCdk22flUhMnkCkWvdRb048+J762BcxxnDCEza+DPgtNs/kI2Uqchi+VQZZWQu
|
||||||
|
MfE58g2UM2Z3se55QfS2vYt4j7pQXjtcTsjOxTRUfzsslahGLcNISGl5kdjL5wrx
|
||||||
|
1Lww3YEl1nucS1QZDt7pcSGNUQldO7e1rh0QxQZlnRzFykOEHJRjDo07Y9bcvhna
|
||||||
|
v5eOPyOD0pxWS1cCAwEAAaOBkjCBjzAdBgNVHQ4EFgQUQOjdjlrpVF4Mplt3HJcP
|
||||||
|
HETom7QwHwYDVR0jBBgwFoAUQOjdjlrpVF4Mplt3HJcPHETom7QwDwYDVR0TAQH/
|
||||||
|
BAUwAwEB/zAaBgNVHREEEzARgQ9hZGFAZXhhbXBsZS5jb20wCwYDVR0PBAQDAgeA
|
||||||
|
MBMGA1UdJQQMMAoGCCsGAQUFBwMEMA0GCSqGSIb3DQEBCwUAA4IBAQBIqGF4hBl2
|
||||||
|
TAMB1yOL+x2J+0ASVarrzfyyVRObY+BZ/GpLm8Dj3bE9kn7qPev79w5jXijvE39a
|
||||||
|
hZjDoJZllwVqlGMJ6AmdCGEd0w1A+ifpxIJ6IK6q98HOoMENGKQgDku1hQDHIVk/
|
||||||
|
laeQLLxZMv+9YlzQLImGI29ItGfENK6bvjK9UirrZcAheiJHB7fA9Z28TfFH+MsO
|
||||||
|
JnBTanGkswxYI2g2JnVbfsKSzGUziS8Pa5LI4wQBjNvv9KLWKo2ORlGeQymvTU+k
|
||||||
|
/J9JO7Bx3jJaeJIKKu+nRTGceQM8OjjwqW9EArXVfa972Ih9n+Xv+Wf+hzEhp6Fa
|
||||||
|
cOm+5s0yIUCKMYICXjCCAloCAQEwTDA0MRUwEwYDVQQDDAxBZGEgTG92ZWxhY2Ux
|
||||||
|
GzAZBgNVBAoMEkFuYWx5dGljYWwgRW5naW5lcwIUPg49mvsUaCFoIGXWVrE9r5aF
|
||||||
|
VmMwDQYJYIZIAWUDBAIBBQCggeQwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAc
|
||||||
|
BgkqhkiG9w0BCQUxDxcNMjYwOTA1MDgyNzQ4WjAvBgkqhkiG9w0BCQQxIgQgD6tN
|
||||||
|
EuEW5UlYvan88jdbR0HwFJnJxh0YtHUGA9NIyf8weQYJKoZIhvcNAQkPMWwwajAL
|
||||||
|
BglghkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQBAjAKBggqhkiG9w0D
|
||||||
|
BzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYIKoZI
|
||||||
|
hvcNAwICASgwDQYJKoZIhvcNAQEBBQAEggEAjiWUoJkFmCxdcwpSQTWjRily685M
|
||||||
|
4JQ5832VRlWA3KhCkn0/rszmGCsCTt0DABEVYMW1N6rN0n0iLKyfCeUSd/PPUAV/
|
||||||
|
vPB7om/yhBZpSKSCYkAj5L8qsss8pFQUG3R5m8Ppr1d7EornrvEqZrKsk7Kx+2O5
|
||||||
|
lFPLREJGYKgH5h8r84dHzOGzOlR6JUWjmQTHBUCGdeHJvbNhzuLZ2BqoSuUc2WpF
|
||||||
|
YsgTbbIfPIwZdTMePmPr+c0LbIhDM9KBa/Rz9eYF9NR+D/foFvUCgL/KWpKggamQ
|
||||||
|
kV5guKvOqsaKf4/db188IuREbnGUwscyTuNUu9u+sKhiYgd02tVrKdYPhA==
|
||||||
|
|
||||||
|
------12D00EBC0B5D3352F0E2F25B1A5D3552--
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
To: [email protected]
|
||||||
|
From: Ada Lovelace <[email protected]>
|
||||||
|
Subject: A note
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----12D00EBC0B5D3352F0E2F25B1A5D3552"
|
||||||
|
|
||||||
|
This is an S/MIME signed message
|
||||||
|
|
||||||
|
------12D00EBC0B5D3352F0E2F25B1A5D3552
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
The Analytical Engine has no pretensions whatsoever to originate anything.
|
||||||
|
|
||||||
|
------12D00EBC0B5D3352F0E2F25B1A5D3552
|
||||||
|
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
Content-Disposition: attachment; filename="smime.p7s"
|
||||||
|
|
||||||
|
MIIGJwYJKoZIhvcNAQcCoIIGGDCCBhQCAQExDzANBglghkgBZQMEAgEFADALBgkq
|
||||||
|
hkiG9w0BBwGgggONMIIDiTCCAnGgAwIBAgIUPg49mvsUaCFoIGXWVrE9r5aFVmMw
|
||||||
|
DQYJKoZIhvcNAQELBQAwNDEVMBMGA1UEAwwMQWRhIExvdmVsYWNlMRswGQYDVQQK
|
||||||
|
DBJBbmFseXRpY2FsIEVuZ2luZXMwHhcNMjYwOTA1MDgyNzQ4WhcNMzYwOTAyMDgy
|
||||||
|
NzQ4WjA0MRUwEwYDVQQDDAxBZGEgTG92ZWxhY2UxGzAZBgNVBAoMEkFuYWx5dGlj
|
||||||
|
YWwgRW5naW5lczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJSBFbrB
|
||||||
|
kSLTrHoufW5WNYoHfPQYd+keeSsZnhl8NGcTUioXLFPgX+ueolebMBRvSQ+eFnXV
|
||||||
|
cYgDtu4ye5qfyYL3WvCWQoiwgu2nP8z6kFTiQKlu2ZRCYsm/0+DSD28wHQFx+9Ns
|
||||||
|
lLYCdk22flUhMnkCkWvdRb048+J762BcxxnDCEza+DPgtNs/kI2Uqchi+VQZZWQu
|
||||||
|
MfE58g2UM2Z3se55QfS2vYt4j7pQXjtcTsjOxTRUfzsslahGLcNISGl5kdjL5wrx
|
||||||
|
1Lww3YEl1nucS1QZDt7pcSGNUQldO7e1rh0QxQZlnRzFykOEHJRjDo07Y9bcvhna
|
||||||
|
v5eOPyOD0pxWS1cCAwEAAaOBkjCBjzAdBgNVHQ4EFgQUQOjdjlrpVF4Mplt3HJcP
|
||||||
|
HETom7QwHwYDVR0jBBgwFoAUQOjdjlrpVF4Mplt3HJcPHETom7QwDwYDVR0TAQH/
|
||||||
|
BAUwAwEB/zAaBgNVHREEEzARgQ9hZGFAZXhhbXBsZS5jb20wCwYDVR0PBAQDAgeA
|
||||||
|
MBMGA1UdJQQMMAoGCCsGAQUFBwMEMA0GCSqGSIb3DQEBCwUAA4IBAQBIqGF4hBl2
|
||||||
|
TAMB1yOL+x2J+0ASVarrzfyyVRObY+BZ/GpLm8Dj3bE9kn7qPev79w5jXijvE39a
|
||||||
|
hZjDoJZllwVqlGMJ6AmdCGEd0w1A+ifpxIJ6IK6q98HOoMENGKQgDku1hQDHIVk/
|
||||||
|
laeQLLxZMv+9YlzQLImGI29ItGfENK6bvjK9UirrZcAheiJHB7fA9Z28TfFH+MsO
|
||||||
|
JnBTanGkswxYI2g2JnVbfsKSzGUziS8Pa5LI4wQBjNvv9KLWKo2ORlGeQymvTU+k
|
||||||
|
/J9JO7Bx3jJaeJIKKu+nRTGceQM8OjjwqW9EArXVfa972Ih9n+Xv+Wf+hzEhp6Fa
|
||||||
|
cOm+5s0yIUCKMYICXjCCAloCAQEwTDA0MRUwEwYDVQQDDAxBZGEgTG92ZWxhY2Ux
|
||||||
|
GzAZBgNVBAoMEkFuYWx5dGljYWwgRW5naW5lcwIUPg49mvsUaCFoIGXWVrE9r5aF
|
||||||
|
VmMwDQYJYIZIAWUDBAIBBQCggeQwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAc
|
||||||
|
BgkqhkiG9w0BCQUxDxcNMjYwOTA1MDgyNzQ4WjAvBgkqhkiG9w0BCQQxIgQgD6tN
|
||||||
|
EuEW5UlYvan88jdbR0HwFJnJxh0YtHUGA9NIyf8weQYJKoZIhvcNAQkPMWwwajAL
|
||||||
|
BglghkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQBAjAKBggqhkiG9w0D
|
||||||
|
BzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYIKoZI
|
||||||
|
hvcNAwICASgwDQYJKoZIhvcNAQEBBQAEggEAjiWUoJkFmCxdcwpSQTWjRily685M
|
||||||
|
4JQ5832VRlWA3KhCkn0/rszmGCsCTt0DABEVYMW1N6rN0n0iLKyfCeUSd/PPUAV/
|
||||||
|
vPB7om/yhBZpSKSCYkAj5L8qsss8pFQUG3R5m8Ppr1d7EornrvEqZrKsk7Kx+2O5
|
||||||
|
lFPLREJGYKgH5h8r84dHzOGzOlR6JUWjmQTHBUCGdeHJvbNhzuLZ2BqoSuUc2WpF
|
||||||
|
YsgTbbIfPIwZdTMePmPr+c0LbIhDM9KBa/Rz9eYF9NR+D/foFvUCgL/KWpKggamQ
|
||||||
|
kV5guKvOqsaKf4/db188IuREbnGUwscyTuNUu9u+sKhiYgd02tVrKdYPhA==
|
||||||
|
|
||||||
|
------12D00EBC0B5D3352F0E2F25B1A5D3552--
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
To: [email protected]
|
||||||
|
From: Ada Lovelace <[email protected]>
|
||||||
|
Subject: Not really Ada
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----A376D763C7F4750971F7C04B27A38CA7"
|
||||||
|
|
||||||
|
This is an S/MIME signed message
|
||||||
|
|
||||||
|
------A376D763C7F4750971F7C04B27A38CA7
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
The Analytical Engine has no pretensions whatever to originate anything.
|
||||||
|
|
||||||
|
------A376D763C7F4750971F7C04B27A38CA7
|
||||||
|
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
Content-Disposition: attachment; filename="smime.p7s"
|
||||||
|
|
||||||
|
MIIFygYJKoZIhvcNAQcCoIIFuzCCBbcCAQExDzANBglghkgBZQMEAgEFADALBgkq
|
||||||
|
hkiG9w0BBwGgggNMMIIDSDCCAjCgAwIBAgIUFdKNfhOvBCjZ11I7PjX3Sm6PShQw
|
||||||
|
DQYJKoZIhvcNAQELBQAwGDEWMBQGA1UEAwwNU29tZWJvZHkgRWxzZTAeFw0yNjA5
|
||||||
|
MDUwODI3NDhaFw0zNjA5MDIwODI3NDhaMBgxFjAUBgNVBAMMDVNvbWVib2R5IEVs
|
||||||
|
c2UwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCldgteGIpI7bziik2P
|
||||||
|
losrdYWJKZSg/EJ44am9Babze+NCJTxMvE/fzYDUnUgUyL7XKU6dahkOBRrKEjLH
|
||||||
|
EnRnTcr8k6ZqskFJwwWaSAHjvITgHOQ7wGMIwcJldNw/Y+4ZQHeHUncTbaG8byN7
|
||||||
|
dVtl4MG4PodWZMYXFUKH2bEmPQnrTmKfoh/ixOlVnNxu4+S/G9qH+EI8sZyrByIW
|
||||||
|
PY6ChWXDo3C57ifJCtsqvSDiHYxEN9dN/DHglK38bzQOw4Q74XTowiL47+pmU0bF
|
||||||
|
DSg27q2e/h/DHAHs18W/XDcDkxpEOH/B0e/GtAK/R5qAgfmnVvI3f7wRifW4mj/P
|
||||||
|
eEqfAgMBAAGjgYkwgYYwHQYDVR0OBBYEFLYf2wgKUplLO+bSX5VCspywSOJJMB8G
|
||||||
|
A1UdIwQYMBaAFLYf2wgKUplLO+bSX5VCspywSOJJMA8GA1UdEwEB/wQFMAMBAf8w
|
||||||
|
HgYDVR0RBBcwFYETbWFsbG9yeUBleGFtcGxlLm5ldDATBgNVHSUEDDAKBggrBgEF
|
||||||
|
BQcDBDANBgkqhkiG9w0BAQsFAAOCAQEALkRLkusftqrRFNkhbZfbOwt4KOtfFkQn
|
||||||
|
Yn4JzOYNfueSidrWKY3F2s3XfBichBePV6t1toOj0+eagYN+xiI9KftyykVVYb5/
|
||||||
|
dPZlHLRgRFavxlqLLg25bBUEzpw3LpaMMa62Zhc1Cp0N8iATVk9vpMxN/DFN2w6H
|
||||||
|
qe+0CoQYRM8aW/D3aos+EYKbNsB1ZV0AZzt/MJYeJvRhp7oH2QA4sXp82f0F0RAV
|
||||||
|
uSZCX38WzbpfvlDOoasUUlOdPEvxBBaQIptKG1q2DGjqLZUiHyyontdjzR6+VaiV
|
||||||
|
ypB8gs7/oFSKNoQEW8wzoEnubXh70A36Pqr1VFFZtLkNJDUfbBYzPjGCAkIwggI+
|
||||||
|
AgEBMDAwGDEWMBQGA1UEAwwNU29tZWJvZHkgRWxzZQIUFdKNfhOvBCjZ11I7PjX3
|
||||||
|
Sm6PShQwDQYJYIZIAWUDBAIBBQCggeQwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEH
|
||||||
|
ATAcBgkqhkiG9w0BCQUxDxcNMjYwOTA1MDgyNzQ4WjAvBgkqhkiG9w0BCQQxIgQg
|
||||||
|
D6tNEuEW5UlYvan88jdbR0HwFJnJxh0YtHUGA9NIyf8weQYJKoZIhvcNAQkPMWww
|
||||||
|
ajALBglghkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQBAjAKBggqhkiG
|
||||||
|
9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYI
|
||||||
|
KoZIhvcNAwICASgwDQYJKoZIhvcNAQEBBQAEggEAHKJmt4Jf+gY/NkHKLny77T/J
|
||||||
|
D1syA3lFZ008iTGxmANfAUwVQWy7fHGzPmf2FBv3yj+7lbSAJ4b0JITClZ01EejW
|
||||||
|
QGdhMrmVAgd0M55rCEdcLrk7hPsZQ7UOdjc2O22cS2ZBwZGkS4re8a0qy5D2tHAh
|
||||||
|
0fnmu0zDoVwZuo78Panc96th3SjSpLlJU8jwMyiwlRHXzP0nHO8CPWSDMKzi5+8H
|
||||||
|
EHDvcjivzGntVOHvafeokE2NmrIvJpCEvMEMVyVmysgUENTQJYOIhEbXu2ktK79f
|
||||||
|
jx+w6iYUZjnpEHJ79OLArZsV6+e7x9nq6lhA3zSt2CwpOGvfRM17tN0c+OALpg==
|
||||||
|
|
||||||
|
------A376D763C7F4750971F7C04B27A38CA7--
|
||||||
|
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { judge, shouldRemember, verifyMessage, type KnownSigner } from "../verify";
|
||||||
|
import { certCovers } from "../x509";
|
||||||
|
import { parseMime, toCanonicalCrlf } from "../mime";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These fixtures are real. Each was produced by `openssl smime -sign` against a
|
||||||
|
* generated certificate, not written by hand — a hand-built signed message
|
||||||
|
* tests the parser against the author's belief about the format, agrees with
|
||||||
|
* every mistake in it, and is exactly how a verifier ends up passing its own
|
||||||
|
* suite and failing on the first message anybody actually sends.
|
||||||
|
*
|
||||||
|
* The tampered fixture is the same signed message with one word of the body
|
||||||
|
* changed and the signature left alone, which is the case the whole feature
|
||||||
|
* exists to catch.
|
||||||
|
*/
|
||||||
|
// Read through the filesystem rather than an import, so the bytes arrive
|
||||||
|
// exactly as they were signed. A bundler transform in the middle -- even one
|
||||||
|
// that only touched line endings -- would be testing the transform.
|
||||||
|
const fixture = (name: string) => new Uint8Array(readFileSync(resolve(__dirname, "fixtures", name)));
|
||||||
|
|
||||||
|
describe("a genuinely signed message", () => {
|
||||||
|
it("verifies an RSA signature and reads the signer off the certificate", async () => {
|
||||||
|
const result = await verifyMessage(fixture("signed-rsa.eml"));
|
||||||
|
expect(result.kind).toBe("intact");
|
||||||
|
if (result.kind !== "intact") return;
|
||||||
|
expect(result.cert.subject.commonName).toBe("Ada Lovelace");
|
||||||
|
expect(result.cert.emails).toContain("[email protected]");
|
||||||
|
expect(result.cert.fingerprint).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(result.signer.digest).toBe("SHA-256");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies an ECDSA signature, whose r and s need re-packing for WebCrypto", async () => {
|
||||||
|
const result = await verifyMessage(fixture("signed-ec.eml"));
|
||||||
|
expect(result.kind).toBe("intact");
|
||||||
|
if (result.kind !== "intact") return;
|
||||||
|
expect(result.cert.subject.commonName).toBe("Grace Hopper");
|
||||||
|
expect(result.cert.publicKey).toEqual({ kind: "ec", namedCurve: "P-256" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches a body edited after signing", async () => {
|
||||||
|
const result = await verifyMessage(fixture("signed-tampered.eml"));
|
||||||
|
expect(result.kind).toBe("broken");
|
||||||
|
if (result.kind !== "broken") return;
|
||||||
|
expect(result.reason).toBe("digest-mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing is signed when nothing is", async () => {
|
||||||
|
const plain = new TextEncoder().encode("From: [email protected]\r\nSubject: hi\r\n\r\nJust text.\r\n");
|
||||||
|
expect((await verifyMessage(plain)).kind).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declines OpenPGP by name, rather than as an unknown format", async () => {
|
||||||
|
const pgp = new TextEncoder().encode(
|
||||||
|
'From: [email protected]\r\nContent-Type: multipart/signed; protocol="application/pgp-signature"; boundary="b"\r\n\r\n--b\r\nContent-Type: text/plain\r\n\r\nhi\r\n--b\r\nContent-Type: application/pgp-signature\r\n\r\nsig\r\n--b--\r\n',
|
||||||
|
);
|
||||||
|
const result = await verifyMessage(pgp);
|
||||||
|
expect(result.kind).toBe("unsupported");
|
||||||
|
if (result.kind !== "unsupported") return;
|
||||||
|
// A code, so the sentence can be translated where it is shown.
|
||||||
|
expect(result.reason).toBe("openpgp");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what the signature is allowed to mean", () => {
|
||||||
|
const ada = "[email protected]";
|
||||||
|
|
||||||
|
it("a first sighting is pinned, and says so", async () => {
|
||||||
|
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||||
|
const report = judge(crypto, ada, undefined);
|
||||||
|
expect(report.trust).toBe("first-seen");
|
||||||
|
expect(report.warnings).toEqual([]);
|
||||||
|
expect(shouldRemember(report)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the same certificate again is recognised", async () => {
|
||||||
|
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||||
|
if (crypto.kind !== "intact") throw new Error("fixture should verify");
|
||||||
|
const known: KnownSigner = { fingerprint: crypto.cert.fingerprint, name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" };
|
||||||
|
const report = judge(crypto, ada, known);
|
||||||
|
expect(report.trust).toBe("same-as-before");
|
||||||
|
// Nothing to write: it already matches what is stored.
|
||||||
|
expect(shouldRemember(report)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a different certificate for a known address is the loud case", async () => {
|
||||||
|
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||||
|
const known: KnownSigner = { fingerprint: "0".repeat(64), name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" };
|
||||||
|
const report = judge(crypto, ada, known);
|
||||||
|
expect(report.trust).toBe("changed");
|
||||||
|
expect(report.previous).toBe(known);
|
||||||
|
// A changed signer must never overwrite the pin -- that would launder the
|
||||||
|
// very substitution this is here to report.
|
||||||
|
expect(shouldRemember(report)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notices a valid signature by a certificate for somebody else", async () => {
|
||||||
|
const crypto = await verifyMessage(fixture("signed-wrong-address.eml"));
|
||||||
|
expect(crypto.kind).toBe("intact");
|
||||||
|
const report = judge(crypto, ada, undefined);
|
||||||
|
expect(report.warnings).toContain("address-mismatch");
|
||||||
|
// Cryptographically fine, and still not to be pinned as Ada's signer.
|
||||||
|
expect(shouldRemember(report)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an expired certificate without calling the signature broken", async () => {
|
||||||
|
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||||
|
const report = judge(crypto, ada, undefined, new Date("2099-01-01T00:00:00Z"));
|
||||||
|
expect(report.crypto.kind).toBe("intact");
|
||||||
|
expect(report.warnings).toContain("certificate-expired");
|
||||||
|
expect(shouldRemember(report)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a non-verifying result straight through with no trust claim", () => {
|
||||||
|
const report = judge({ kind: "broken", reason: "signature-mismatch" }, ada, undefined);
|
||||||
|
expect(report.trust).toBeUndefined();
|
||||||
|
expect(shouldRemember(report)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("matching a certificate to an address", () => {
|
||||||
|
it("is case-insensitive, as addresses are", async () => {
|
||||||
|
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||||
|
if (crypto.kind !== "intact") throw new Error("fixture should verify");
|
||||||
|
expect(certCovers(crypto.cert, "[email protected]")).toBe(true);
|
||||||
|
expect(certCovers(crypto.cert, "[email protected]")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canonicalisation", () => {
|
||||||
|
it("turns a lone LF into CRLF and leaves an existing CRLF alone", () => {
|
||||||
|
const mixed = new TextEncoder().encode("a\nb\r\nc\n");
|
||||||
|
expect(new TextDecoder().decode(toCanonicalCrlf(mixed))).toBe("a\r\nb\r\nc\r\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op on content that is already canonical", () => {
|
||||||
|
const already = new TextEncoder().encode("a\r\nb\r\n");
|
||||||
|
expect(toCanonicalCrlf(already)).toBe(already);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The reason canonicalisation is applied at all: a store that hands back a
|
||||||
|
* message with bare LFs would otherwise fail every signature it holds, and
|
||||||
|
* the message would look identical on screen while doing it.
|
||||||
|
*/
|
||||||
|
it("verifies a signed message whose line endings were flattened in storage", async () => {
|
||||||
|
const original = fixture("signed-rsa.eml");
|
||||||
|
const flattened = new TextEncoder().encode(new TextDecoder().decode(original).replace(/\r\n/g, "\n"));
|
||||||
|
expect((await verifyMessage(flattened)).kind).toBe("intact");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reading the message structure", () => {
|
||||||
|
it("finds the two parts of a signed message and keeps their bytes intact", () => {
|
||||||
|
const root = parseMime(fixture("signed-rsa.eml"));
|
||||||
|
expect(root.contentType).toBe("multipart/signed");
|
||||||
|
expect(root.parts).toHaveLength(2);
|
||||||
|
expect(root.parts[0]!.contentType).toBe("text/plain");
|
||||||
|
expect(root.parts[1]!.contentType).toBe("application/x-pkcs7-signature");
|
||||||
|
// The signed part keeps its own headers: they are inside what was signed.
|
||||||
|
expect(new TextDecoder().decode(root.parts[0]!.raw)).toMatch(/^Content-Type: text\/plain/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* CMS SignedData (RFC 5652), enough of it to check a detached S/MIME signature.
|
||||||
|
*
|
||||||
|
* The one place this is easy to get quietly wrong is what the signature covers.
|
||||||
|
* When signed attributes are present — and for S/MIME they always are, because
|
||||||
|
* the content type and message digest are required — the signature is **not**
|
||||||
|
* over the message. It is over the DER encoding of the SignedAttributes, and
|
||||||
|
* those appear in the blob tagged `[0] IMPLICIT`, which must be re-tagged to
|
||||||
|
* the universal `SET OF` before hashing. Skip that and every valid signature
|
||||||
|
* fails; hash the message instead and every signature "passes", which is very
|
||||||
|
* much worse. `signedAttrsForSigning` is that step, kept on its own so it can
|
||||||
|
* be tested on its own.
|
||||||
|
*/
|
||||||
|
import { at, children, DerError, expect, integerHex, oid, parse, TAG, time, type Asn1 } from "./der";
|
||||||
|
|
||||||
|
const OID = {
|
||||||
|
signedData: "1.2.840.113549.1.7.2",
|
||||||
|
data: "1.2.840.113549.1.7.1",
|
||||||
|
contentType: "1.2.840.113549.1.9.3",
|
||||||
|
messageDigest: "1.2.840.113549.1.9.4",
|
||||||
|
signingTime: "1.2.840.113549.1.9.5",
|
||||||
|
|
||||||
|
sha256: "2.16.840.1.101.3.4.2.1",
|
||||||
|
sha384: "2.16.840.1.101.3.4.2.2",
|
||||||
|
sha512: "2.16.840.1.101.3.4.2.3",
|
||||||
|
sha1: "1.3.14.3.2.26",
|
||||||
|
|
||||||
|
rsaEncryption: "1.2.840.113549.1.1.1",
|
||||||
|
sha256WithRsa: "1.2.840.113549.1.1.11",
|
||||||
|
sha384WithRsa: "1.2.840.113549.1.1.12",
|
||||||
|
sha512WithRsa: "1.2.840.113549.1.1.13",
|
||||||
|
rsaPss: "1.2.840.113549.1.1.10",
|
||||||
|
ecdsaWithSha256: "1.2.840.10045.4.3.2",
|
||||||
|
ecdsaWithSha384: "1.2.840.10045.4.3.3",
|
||||||
|
ecdsaWithSha512: "1.2.840.10045.4.3.4",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Digest = "SHA-256" | "SHA-384" | "SHA-512";
|
||||||
|
export type SignatureKind = "rsa-pkcs1" | "rsa-pss" | "ecdsa";
|
||||||
|
|
||||||
|
export interface SignerInfo {
|
||||||
|
digest: Digest;
|
||||||
|
signature: SignatureKind;
|
||||||
|
/** Raw signature bytes. */
|
||||||
|
value: Uint8Array;
|
||||||
|
/** DER of the SignedAttributes, already re-tagged as a SET OF, ready to hash. */
|
||||||
|
signedAttrs: Uint8Array;
|
||||||
|
/** The messageDigest signed attribute: what the content must hash to. */
|
||||||
|
messageDigest: Uint8Array;
|
||||||
|
/** Claimed signing time, if the signer included one. Not evidence of anything. */
|
||||||
|
signingTime?: Date;
|
||||||
|
/** Issuer name DER + serial, how the signer's certificate is usually named. */
|
||||||
|
issuerDer?: Uint8Array;
|
||||||
|
serial?: string;
|
||||||
|
/** subjectKeyIdentifier, used instead of issuer-and-serial by version 3 signers. */
|
||||||
|
subjectKeyId?: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignedData {
|
||||||
|
/** DER of each certificate carried along, in the order they appeared. */
|
||||||
|
certificates: Uint8Array[];
|
||||||
|
signers: SignerInfo[];
|
||||||
|
/** Present only for an opaque signature, where the content travels inside. */
|
||||||
|
encapsulatedContent?: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a PKCS#7 / CMS blob into the parts a verifier needs. */
|
||||||
|
export function parseSignedData(der: Uint8Array): SignedData {
|
||||||
|
const info = children(expect(parse(der), TAG.sequence, "a ContentInfo"));
|
||||||
|
if (oid(at(info, 0, "a content type")) !== OID.signedData) throw new DerError("Not a CMS SignedData.");
|
||||||
|
const wrapper = at(info, 1, "the SignedData [0]");
|
||||||
|
const signedData = children(expect(children(wrapper)[0] ?? wrapper, TAG.sequence, "a SignedData"));
|
||||||
|
|
||||||
|
// SignedData ::= version, digestAlgorithms, encapContentInfo,
|
||||||
|
// [0] certificates, [1] crls, signerInfos
|
||||||
|
const encap = children(expect(at(signedData, 2, "an encapContentInfo"), TAG.sequence, "an encapContentInfo"));
|
||||||
|
const encapsulatedContent = encap[1] ? children(encap[1])[0]?.content : undefined;
|
||||||
|
|
||||||
|
const certificates: Uint8Array[] = [];
|
||||||
|
const certSet = signedData.find((n) => n.cls === 2 && n.tag === 0);
|
||||||
|
if (certSet) {
|
||||||
|
for (const c of children(certSet)) {
|
||||||
|
// Only plain certificates; the other CHOICE arms are context-tagged and
|
||||||
|
// are attribute certificates, which nothing here knows how to read.
|
||||||
|
if (c.cls === 0 && c.tag === TAG.sequence) certificates.push(c.bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const signerSet = signedData[signedData.length - 1];
|
||||||
|
if (!signerSet || signerSet.tag !== TAG.set) throw new DerError("No signerInfos.");
|
||||||
|
const signers = children(signerSet).map(readSigner);
|
||||||
|
if (signers.length === 0) throw new DerError("SignedData carries no signer.");
|
||||||
|
|
||||||
|
return { certificates, signers, encapsulatedContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSigner(node: Asn1): SignerInfo {
|
||||||
|
const p = children(expect(node, TAG.sequence, "a SignerInfo"));
|
||||||
|
let i = 1; // skip version
|
||||||
|
|
||||||
|
// sid ::= issuerAndSerialNumber | [0] subjectKeyIdentifier
|
||||||
|
const sid = at(p, i++, "a signer identifier");
|
||||||
|
let issuerDer: Uint8Array | undefined;
|
||||||
|
let serial: string | undefined;
|
||||||
|
let subjectKeyId: Uint8Array | undefined;
|
||||||
|
if (sid.cls === 2 && sid.tag === 0) {
|
||||||
|
subjectKeyId = sid.content;
|
||||||
|
} else {
|
||||||
|
const pair = children(sid);
|
||||||
|
issuerDer = at(pair, 0, "an issuer name").bytes;
|
||||||
|
serial = integerHex(at(pair, 1, "a serial number"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = digestFrom(oid(at(children(at(p, i++, "a digest algorithm")), 0, "a digest algorithm id")));
|
||||||
|
|
||||||
|
// [0] IMPLICIT SignedAttributes, optional but always present for S/MIME.
|
||||||
|
const attrsNode = p[i]?.cls === 2 && p[i]?.tag === 0 ? p[i++]! : undefined;
|
||||||
|
if (!attrsNode) throw new DerError("Signature carries no signed attributes; S/MIME requires them.");
|
||||||
|
|
||||||
|
const algNode = children(at(p, i++, "a signature algorithm"));
|
||||||
|
const signature = signatureFrom(oid(at(algNode, 0, "a signature algorithm id")));
|
||||||
|
const value = expect(at(p, i++, "a signature"), TAG.octetString, "a signature").content;
|
||||||
|
|
||||||
|
const attrs = children(attrsNode);
|
||||||
|
const messageDigest = findAttr(attrs, OID.messageDigest, (v) => expect(v, TAG.octetString, "a message digest").content);
|
||||||
|
if (!messageDigest) throw new DerError("Signature has no messageDigest attribute.");
|
||||||
|
const contentType = findAttr(attrs, OID.contentType, (v) => oid(v));
|
||||||
|
if (contentType && contentType !== OID.data) throw new DerError(`Signed content type is ${contentType}, not plain data.`);
|
||||||
|
// A claimed signing time is shown, never checked: the signer chose it, so it
|
||||||
|
// is a statement rather than evidence. An unreadable one must not fail the
|
||||||
|
// signature, which is why this swallows rather than throws.
|
||||||
|
const signingTime = findAttr(attrs, OID.signingTime, (v) => {
|
||||||
|
try {
|
||||||
|
return time(v);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { digest, signature, value, signedAttrs: signedAttrsForSigning(attrsNode), messageDigest, signingTime, issuerDer, serial, subjectKeyId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bytes the signature is actually over.
|
||||||
|
*
|
||||||
|
* SignedAttributes travel as `[0] IMPLICIT`, tag 0xA0. RFC 5652 §5.4 says the
|
||||||
|
* signature is computed over their DER encoding as a `SET OF`, tag 0x31. Only
|
||||||
|
* the identifier octet changes; the length and contents are already correct,
|
||||||
|
* which is why this is a single byte and also why it is so easy to miss.
|
||||||
|
*/
|
||||||
|
export function signedAttrsForSigning(attrs: Asn1): Uint8Array {
|
||||||
|
const copy = attrs.bytes.slice();
|
||||||
|
copy[0] = 0x31;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAttr<T>(attrs: Asn1[], want: string, read: (v: Asn1) => T): T | undefined {
|
||||||
|
for (const attr of attrs) {
|
||||||
|
const kv = children(attr);
|
||||||
|
if (kv.length < 2) continue;
|
||||||
|
if (oid(at(kv, 0, "an attribute type")) !== want) continue;
|
||||||
|
const values = children(at(kv, 1, "an attribute value set"));
|
||||||
|
if (values[0]) return read(values[0]);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function digestFrom(o: string): Digest {
|
||||||
|
if (o === OID.sha256) return "SHA-256";
|
||||||
|
if (o === OID.sha384) return "SHA-384";
|
||||||
|
if (o === OID.sha512) return "SHA-512";
|
||||||
|
// SHA-1 is refused rather than supported. A signature nobody can forge in
|
||||||
|
// practice today is still one this should not be putting a tick beside.
|
||||||
|
if (o === OID.sha1) throw new DerError("Signed with SHA-1, which is too weak to report as verified.");
|
||||||
|
throw new DerError(`Unsupported digest algorithm ${o}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function signatureFrom(o: string): SignatureKind {
|
||||||
|
if (o === OID.rsaEncryption || o === OID.sha256WithRsa || o === OID.sha384WithRsa || o === OID.sha512WithRsa) return "rsa-pkcs1";
|
||||||
|
if (o === OID.rsaPss) return "rsa-pss";
|
||||||
|
if (o === OID.ecdsaWithSha256 || o === OID.ecdsaWithSha384 || o === OID.ecdsaWithSha512) return "ecdsa";
|
||||||
|
throw new DerError(`Unsupported signature algorithm ${o}.`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* Just enough DER to read a CMS signature and an X.509 certificate.
|
||||||
|
*
|
||||||
|
* This is deliberately small. It is not a general ASN.1 library and should not
|
||||||
|
* grow into one: everything here exists because some byte of a signed message
|
||||||
|
* has to be looked at, and a parser that can read shapes nothing sends is a
|
||||||
|
* parser with corners nobody has tested.
|
||||||
|
*
|
||||||
|
* Two rules it keeps, both of which matter for verification rather than for
|
||||||
|
* tidiness:
|
||||||
|
*
|
||||||
|
* - Every node keeps `bytes`, the whole tag-length-value as it arrived. A
|
||||||
|
* signature is computed over encoded bytes, so anything that re-encodes a
|
||||||
|
* structure it means to hash has already lost. Nothing here re-encodes.
|
||||||
|
* - Indefinite lengths are refused rather than guessed at. S/MIME signatures
|
||||||
|
* are DER, which forbids them; a blob using one is either BER from an
|
||||||
|
* unusual producer or is not what it claims, and treating the two alike
|
||||||
|
* would mean inventing a parse for input this has never seen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Asn1 {
|
||||||
|
/** Tag number, without the class and constructed bits. */
|
||||||
|
tag: number;
|
||||||
|
/** 0 universal, 1 application, 2 context-specific, 3 private. */
|
||||||
|
cls: number;
|
||||||
|
constructed: boolean;
|
||||||
|
/** Content octets: the V of TLV. */
|
||||||
|
content: Uint8Array;
|
||||||
|
/** The whole TLV as it arrived, for anything that must hash or re-present it. */
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TAG = {
|
||||||
|
boolean: 0x01,
|
||||||
|
integer: 0x02,
|
||||||
|
bitString: 0x03,
|
||||||
|
octetString: 0x04,
|
||||||
|
null: 0x05,
|
||||||
|
oid: 0x06,
|
||||||
|
utf8String: 0x0c,
|
||||||
|
sequence: 0x10,
|
||||||
|
set: 0x11,
|
||||||
|
printableString: 0x13,
|
||||||
|
ia5String: 0x16,
|
||||||
|
utcTime: 0x17,
|
||||||
|
generalizedTime: 0x18,
|
||||||
|
bmpString: 0x1e,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export class DerError extends Error {}
|
||||||
|
|
||||||
|
/** Read one TLV at `offset`. Returns the node and where the next one starts. */
|
||||||
|
export function readNode(buf: Uint8Array, offset = 0): { node: Asn1; next: number } {
|
||||||
|
if (offset + 2 > buf.length) throw new DerError("Truncated: no room for a tag and a length.");
|
||||||
|
const id = buf[offset]!;
|
||||||
|
const cls = id >> 6;
|
||||||
|
const constructed = (id & 0x20) !== 0;
|
||||||
|
let tag = id & 0x1f;
|
||||||
|
let i = offset + 1;
|
||||||
|
|
||||||
|
// High-tag-number form: 0b11111 says the number continues in the following
|
||||||
|
// octets, seven bits at a time. Rare, but a context tag above 30 is legal.
|
||||||
|
if (tag === 0x1f) {
|
||||||
|
tag = 0;
|
||||||
|
for (;;) {
|
||||||
|
if (i >= buf.length) throw new DerError("Truncated inside a multi-byte tag.");
|
||||||
|
const b = buf[i++]!;
|
||||||
|
tag = (tag << 7) | (b & 0x7f);
|
||||||
|
if ((b & 0x80) === 0) break;
|
||||||
|
if (tag > 0xffffff) throw new DerError("Unreasonable tag number.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i >= buf.length) throw new DerError("Truncated: no length octet.");
|
||||||
|
const first = buf[i++]!;
|
||||||
|
let length: number;
|
||||||
|
if (first < 0x80) {
|
||||||
|
length = first;
|
||||||
|
} else if (first === 0x80) {
|
||||||
|
throw new DerError("Indefinite length: this is BER, and a signature must be DER.");
|
||||||
|
} else {
|
||||||
|
const n = first & 0x7f;
|
||||||
|
if (n > 4) throw new DerError("Length field too large to be real.");
|
||||||
|
if (i + n > buf.length) throw new DerError("Truncated inside a length field.");
|
||||||
|
length = 0;
|
||||||
|
for (let k = 0; k < n; k++) length = length * 256 + buf[i++]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = i + length;
|
||||||
|
if (end > buf.length) throw new DerError(`Truncated: a node claims ${length} bytes and only ${buf.length - i} remain.`);
|
||||||
|
return {
|
||||||
|
node: { tag, cls, constructed, content: buf.subarray(i, end), bytes: buf.subarray(offset, end) },
|
||||||
|
next: end,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a single top-level node, refusing trailing rubbish. */
|
||||||
|
export function parse(buf: Uint8Array): Asn1 {
|
||||||
|
const { node, next } = readNode(buf, 0);
|
||||||
|
if (next !== buf.length) throw new DerError(`${buf.length - next} trailing byte(s) after the top-level value.`);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The immediate children of a constructed node. */
|
||||||
|
export function children(node: Asn1): Asn1[] {
|
||||||
|
if (!node.constructed) throw new DerError("Asked for the children of a primitive value.");
|
||||||
|
const out: Asn1[] = [];
|
||||||
|
let at = 0;
|
||||||
|
while (at < node.content.length) {
|
||||||
|
const { node: child, next } = readNode(node.content, at);
|
||||||
|
out.push(child);
|
||||||
|
at = next;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A child by position, checked, because "undefined" is a poor error message. */
|
||||||
|
export function at(nodes: Asn1[], index: number, what: string): Asn1 {
|
||||||
|
const n = nodes[index];
|
||||||
|
if (!n) throw new DerError(`Missing ${what}.`);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expect(node: Asn1, tag: number, what: string): Asn1 {
|
||||||
|
if (node.cls !== 0 || node.tag !== tag) throw new DerError(`Expected ${what} (universal tag ${tag}), found class ${node.cls} tag ${node.tag}.`);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A context-specific child, e.g. [0] — returns undefined when absent. */
|
||||||
|
export function contextChild(nodes: Asn1[], tag: number): Asn1 | undefined {
|
||||||
|
return nodes.find((n) => n.cls === 2 && n.tag === tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dotted OID, decoded from the packed base-128 form. */
|
||||||
|
export function oid(node: Asn1): string {
|
||||||
|
expect(node, TAG.oid, "an object identifier");
|
||||||
|
const c = node.content;
|
||||||
|
if (c.length === 0) throw new DerError("Empty object identifier.");
|
||||||
|
// The first octet packs two arcs: 40*first + second.
|
||||||
|
const parts = [Math.floor(c[0]! / 40), c[0]! % 40];
|
||||||
|
let value = 0;
|
||||||
|
for (let i = 1; i < c.length; i++) {
|
||||||
|
const b = c[i]!;
|
||||||
|
value = value * 128 + (b & 0x7f);
|
||||||
|
if ((b & 0x80) === 0) {
|
||||||
|
parts.push(value);
|
||||||
|
value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contents of a BIT STRING, refusing the padded case nothing here should meet. */
|
||||||
|
export function bitString(node: Asn1): Uint8Array {
|
||||||
|
expect(node, TAG.bitString, "a bit string");
|
||||||
|
if (node.content.length === 0) throw new DerError("Empty bit string.");
|
||||||
|
const unused = node.content[0]!;
|
||||||
|
if (unused !== 0) throw new DerError(`Bit string with ${unused} unused bits; expected a whole number of bytes.`);
|
||||||
|
return node.content.subarray(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An INTEGER as a hex string, since serial numbers overflow a JS number. */
|
||||||
|
export function integerHex(node: Asn1): string {
|
||||||
|
expect(node, TAG.integer, "an integer");
|
||||||
|
let hex = "";
|
||||||
|
for (const b of node.content) hex += b.toString(16).padStart(2, "0");
|
||||||
|
return hex.replace(/^(00)+(?=.)/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small INTEGER, for versions and the like. */
|
||||||
|
export function integer(node: Asn1): number {
|
||||||
|
expect(node, TAG.integer, "an integer");
|
||||||
|
if (node.content.length > 4) throw new DerError("Integer larger than this reads.");
|
||||||
|
let v = 0;
|
||||||
|
for (const b of node.content) v = v * 256 + b;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTCTime or GeneralizedTime.
|
||||||
|
*
|
||||||
|
* UTCTime carries a two-digit year, and RFC 5280 pins the window: 50-99 mean
|
||||||
|
* 1950-1999 and 00-49 mean 2000-2049. Guessing "20" + yy instead works until
|
||||||
|
* 2050 and then silently dates certificates a century early, which is the kind
|
||||||
|
* of bug that is written once and found by somebody else.
|
||||||
|
*/
|
||||||
|
export function time(node: Asn1): Date {
|
||||||
|
const s = new TextDecoder().decode(node.content);
|
||||||
|
let iso: string;
|
||||||
|
if (node.tag === TAG.utcTime) {
|
||||||
|
const m = /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?Z$/.exec(s);
|
||||||
|
if (!m) throw new DerError(`Unreadable UTCTime "${s}".`);
|
||||||
|
const yy = Number(m[1]);
|
||||||
|
const year = yy >= 50 ? 1900 + yy : 2000 + yy;
|
||||||
|
iso = `${year}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6] ?? "00"}Z`;
|
||||||
|
} else if (node.tag === TAG.generalizedTime) {
|
||||||
|
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?(?:\.\d+)?Z$/.exec(s);
|
||||||
|
if (!m) throw new DerError(`Unreadable GeneralizedTime "${s}".`);
|
||||||
|
iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6] ?? "00"}Z`;
|
||||||
|
} else {
|
||||||
|
throw new DerError(`Expected a time, found tag ${node.tag}.`);
|
||||||
|
}
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) throw new DerError(`Unreadable time "${s}".`);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text from any of the string types a name or an address turns up in. */
|
||||||
|
export function text(node: Asn1): string {
|
||||||
|
if (node.tag === TAG.bmpString) {
|
||||||
|
// UTF-16BE. Rare, but Windows-issued certificates do use it for names.
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i + 1 < node.content.length; i += 2) s += String.fromCharCode((node.content[i]! << 8) | node.content[i + 1]!);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(node.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowercase hex of some bytes, for fingerprints. */
|
||||||
|
export function hex(bytes: Uint8Array): string {
|
||||||
|
let s = "";
|
||||||
|
for (const b of bytes) s += b.toString(16).padStart(2, "0");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Enough MIME to find a signed part and hand back the exact bytes it covers.
|
||||||
|
*
|
||||||
|
* This works on bytes, not on a string, and that is the whole point. A
|
||||||
|
* signature is over an octet sequence: decode it to text, re-encode it, or let
|
||||||
|
* anything normalise a line ending on the way past, and the digest changes
|
||||||
|
* while the message still looks identical on screen. Every part here keeps a
|
||||||
|
* subarray of the original buffer rather than a rebuilt copy.
|
||||||
|
*
|
||||||
|
* The one transformation that *is* applied is a lone LF becoming CRLF, and it
|
||||||
|
* is applied only to the signed part. RFC 1847 requires the protected content
|
||||||
|
* to be in canonical MIME form, which means CRLF; a store that hands back a
|
||||||
|
* message with bare LFs — and they do — would otherwise fail every signature it
|
||||||
|
* has ever held, for a reason nobody could see by looking at the message.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MimePart {
|
||||||
|
/** Lowercased header name to raw value, first occurrence winning. */
|
||||||
|
headers: Map<string, string>;
|
||||||
|
/** Lowercased `type/subtype`, or "text/plain" when unstated. */
|
||||||
|
contentType: string;
|
||||||
|
/** Lowercased content-type parameters. */
|
||||||
|
params: Record<string, string>;
|
||||||
|
/** The body, exactly as it appeared. */
|
||||||
|
body: Uint8Array;
|
||||||
|
/** Headers and body together, exactly as they appeared. */
|
||||||
|
raw: Uint8Array;
|
||||||
|
parts: MimePart[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CR = 13;
|
||||||
|
const LF = 10;
|
||||||
|
|
||||||
|
function indexOfSeq(hay: Uint8Array, needle: number[], from = 0): number {
|
||||||
|
outer: for (let i = from; i + needle.length <= hay.length; i++) {
|
||||||
|
for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer;
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the headers stop: the first blank line, in either line ending. */
|
||||||
|
function headerEnd(buf: Uint8Array): { bodyAt: number; headersEnd: number } {
|
||||||
|
const crlf = indexOfSeq(buf, [CR, LF, CR, LF]);
|
||||||
|
const lf = indexOfSeq(buf, [LF, LF]);
|
||||||
|
if (crlf >= 0 && (lf < 0 || crlf <= lf)) return { headersEnd: crlf, bodyAt: crlf + 4 };
|
||||||
|
if (lf >= 0) return { headersEnd: lf, bodyAt: lf + 2 };
|
||||||
|
return { headersEnd: buf.length, bodyAt: buf.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeaders(block: string): Map<string, string> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
// Unfold first: a continuation line begins with space or tab and belongs to
|
||||||
|
// the header above it. Folding a long boundary parameter is ordinary, so a
|
||||||
|
// parser that reads line by line loses boundaries on real messages.
|
||||||
|
const unfolded = block.replace(/\r?\n[ \t]+/g, " ");
|
||||||
|
for (const line of unfolded.split(/\r?\n/)) {
|
||||||
|
const c = line.indexOf(":");
|
||||||
|
if (c <= 0) continue;
|
||||||
|
const name = line.slice(0, c).trim().toLowerCase();
|
||||||
|
if (!out.has(name)) out.set(name, line.slice(c + 1).trim());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split `text/plain; charset="utf-8"` into its type and its parameters. */
|
||||||
|
export function parseContentType(value: string | undefined): { type: string; params: Record<string, string> } {
|
||||||
|
if (!value) return { type: "text/plain", params: {} };
|
||||||
|
const [head, ...rest] = value.split(";");
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (const p of rest) {
|
||||||
|
const eq = p.indexOf("=");
|
||||||
|
if (eq < 0) continue;
|
||||||
|
const k = p.slice(0, eq).trim().toLowerCase();
|
||||||
|
let v = p.slice(eq + 1).trim();
|
||||||
|
if (v.startsWith('"')) v = v.slice(1, v.lastIndexOf('"') > 0 ? v.lastIndexOf('"') : undefined);
|
||||||
|
params[k] = v;
|
||||||
|
}
|
||||||
|
return { type: (head ?? "").trim().toLowerCase() || "text/plain", params };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a message, or a part of one, into a tree. */
|
||||||
|
export function parseMime(raw: Uint8Array): MimePart {
|
||||||
|
const { bodyAt, headersEnd } = headerEnd(raw);
|
||||||
|
const headers = parseHeaders(new TextDecoder("utf-8", { fatal: false }).decode(raw.subarray(0, headersEnd)));
|
||||||
|
const { type, params } = parseContentType(headers.get("content-type"));
|
||||||
|
const body = raw.subarray(bodyAt);
|
||||||
|
const part: MimePart = { headers, contentType: type, params, body, raw, parts: [] };
|
||||||
|
|
||||||
|
if (type.startsWith("multipart/") && params.boundary) part.parts = splitMultipart(body, params.boundary);
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a multipart body on its boundary.
|
||||||
|
*
|
||||||
|
* The subtle bit is what belongs to a part and what belongs to the delimiter.
|
||||||
|
* RFC 2046 puts the CRLF *before* a boundary line into the delimiter, not into
|
||||||
|
* the part above it. Keeping that CRLF appends two bytes to the signed content
|
||||||
|
* and fails every signature; dropping one too many does the same. So each part
|
||||||
|
* ends at the byte before the CRLF that introduces the next boundary.
|
||||||
|
*/
|
||||||
|
function splitMultipart(body: Uint8Array, boundary: string): MimePart[] {
|
||||||
|
const marker = [...`--${boundary}`].map((c) => c.charCodeAt(0));
|
||||||
|
const offsets: number[] = [];
|
||||||
|
for (let i = 0; i >= 0 && i < body.length; ) {
|
||||||
|
const found = indexOfSeq(body, marker, i);
|
||||||
|
if (found < 0) break;
|
||||||
|
// Only at the start of a line.
|
||||||
|
if (found === 0 || body[found - 1] === LF) offsets.push(found);
|
||||||
|
i = found + marker.length;
|
||||||
|
}
|
||||||
|
if (offsets.length < 2) return [];
|
||||||
|
|
||||||
|
const parts: MimePart[] = [];
|
||||||
|
for (let k = 0; k < offsets.length - 1; k++) {
|
||||||
|
const delimiter = offsets[k]!;
|
||||||
|
// Step over the boundary line itself to reach the part's first header byte.
|
||||||
|
let start = delimiter + marker.length;
|
||||||
|
while (start < body.length && body[start] !== LF) start++;
|
||||||
|
start++;
|
||||||
|
// The part ends before the CRLF that belongs to the *next* delimiter.
|
||||||
|
let end = offsets[k + 1]!;
|
||||||
|
if (end > 0 && body[end - 1] === LF) end--;
|
||||||
|
if (end > 0 && body[end - 1] === CR) end--;
|
||||||
|
if (start < end) parts.push(parseMime(body.subarray(start, end)));
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-first search for the first part matching a predicate. */
|
||||||
|
export function findPart(part: MimePart, want: (p: MimePart) => boolean): MimePart | undefined {
|
||||||
|
if (want(part)) return part;
|
||||||
|
for (const child of part.parts) {
|
||||||
|
const hit = findPart(child, want);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical CRLF form, applied only to content about to be hashed.
|
||||||
|
*
|
||||||
|
* A lone LF becomes CRLF; an existing CRLF is left alone. Nothing else is
|
||||||
|
* touched -- no trailing-whitespace tidying, no re-wrapping -- because every
|
||||||
|
* other "helpful" change is one the signer did not make.
|
||||||
|
*/
|
||||||
|
export function toCanonicalCrlf(bytes: Uint8Array): Uint8Array {
|
||||||
|
let lone = 0;
|
||||||
|
for (let i = 0; i < bytes.length; i++) if (bytes[i] === LF && (i === 0 || bytes[i - 1] !== CR)) lone++;
|
||||||
|
if (lone === 0) return bytes;
|
||||||
|
const out = new Uint8Array(bytes.length + lone);
|
||||||
|
let j = 0;
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
if (bytes[i] === LF && (i === 0 || bytes[i - 1] !== CR)) out[j++] = CR;
|
||||||
|
out[j++] = bytes[i]!;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Undo base64 or quoted-printable so a signature blob can be read as DER. */
|
||||||
|
export function decodeTransfer(part: MimePart): Uint8Array {
|
||||||
|
const encoding = (part.headers.get("content-transfer-encoding") ?? "").trim().toLowerCase();
|
||||||
|
if (encoding === "base64") {
|
||||||
|
const text = new TextDecoder().decode(part.body).replace(/[^A-Za-z0-9+/=]/g, "");
|
||||||
|
const binary = atob(text);
|
||||||
|
const out = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (encoding === "quoted-printable") {
|
||||||
|
const text = new TextDecoder().decode(part.body).replace(/=\r?\n/g, "");
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === "=" && i + 2 < text.length) {
|
||||||
|
out.push(parseInt(text.slice(i + 1, i + 3), 16));
|
||||||
|
i += 2;
|
||||||
|
} else out.push(text.charCodeAt(i));
|
||||||
|
}
|
||||||
|
return new Uint8Array(out);
|
||||||
|
}
|
||||||
|
return part.body;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Checking the signature on the message being read.
|
||||||
|
*
|
||||||
|
* Two things this is careful about, both about not doing work:
|
||||||
|
*
|
||||||
|
* - The verifier is imported dynamically. Signed mail is rare, and DER
|
||||||
|
* parsing plus certificate reading has no business in the bundle everybody
|
||||||
|
* downloads to read an unsigned message.
|
||||||
|
* - Nothing is fetched unless the message says it is signed. The structure
|
||||||
|
* already came with the message, so the common answer costs one string
|
||||||
|
* comparison and no network at all.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { client } from "@/jmap/client";
|
||||||
|
import type { Email, EmailBodyPart, Id } from "@/jmap/types";
|
||||||
|
import { useSettings, type SignerPin } from "@/store/settings";
|
||||||
|
import type { SignatureReport } from "./verify";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many signers are remembered before the oldest pin is dropped.
|
||||||
|
*
|
||||||
|
* A cap is needed because this rides in the account's settings file, which is
|
||||||
|
* fetched on every sign-in. Evicting is not free — a dropped signer is greeted
|
||||||
|
* as new next time, which is a quieter message than it should be — so the limit
|
||||||
|
* is set far above what S/MIME's actual prevalence will produce rather than at
|
||||||
|
* a number that trades safety for bytes.
|
||||||
|
*/
|
||||||
|
const MAX_PINS = 500;
|
||||||
|
|
||||||
|
export type SignatureState = { status: "idle" } | { status: "checking" } | { status: "done"; report: SignatureReport };
|
||||||
|
|
||||||
|
/** Whether anything in this message's structure claims to be signed. */
|
||||||
|
export function structureLooksSigned(part: EmailBodyPart | undefined): boolean {
|
||||||
|
if (!part) return false;
|
||||||
|
if (part.type === "multipart/signed") return true;
|
||||||
|
return (part.subParts ?? []).some(structureLooksSigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSignature(email: Email | undefined, accountId: Id | null): SignatureState {
|
||||||
|
const [state, setState] = useState<SignatureState>({ status: "idle" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!email || !accountId || !structureLooksSigned(email.bodyStructure)) {
|
||||||
|
setState({ status: "idle" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let live = true;
|
||||||
|
setState({ status: "checking" });
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const [{ judge, shouldRemember, verifyMessage }, blob] = await Promise.all([
|
||||||
|
import("./verify"),
|
||||||
|
client.fetchBlob(accountId, email.blobId, "message/rfc822"),
|
||||||
|
]);
|
||||||
|
if (!live) return;
|
||||||
|
|
||||||
|
const raw = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
const from = (email.from?.[0]?.email ?? "").toLowerCase();
|
||||||
|
const crypto = await verifyMessage(raw);
|
||||||
|
const stored = useSettings.getState().settings.knownSigners[from];
|
||||||
|
// A pin this very message created is not corroboration of it. Treated
|
||||||
|
// as absent, so the message that established a signer keeps saying so
|
||||||
|
// however many times it is reopened.
|
||||||
|
const known = stored && stored.messageId === email.id ? undefined : stored;
|
||||||
|
const report = judge(crypto, from, known);
|
||||||
|
if (!live) return;
|
||||||
|
|
||||||
|
if (from && shouldRemember(report) && report.crypto.kind === "intact") {
|
||||||
|
pin(from, {
|
||||||
|
fingerprint: report.crypto.cert.fingerprint,
|
||||||
|
name: report.crypto.cert.subject.commonName || report.crypto.cert.subject.emailAddress || from,
|
||||||
|
firstSeen: new Date().toISOString(),
|
||||||
|
messageId: email.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setState({ status: "done", report });
|
||||||
|
} catch (err) {
|
||||||
|
if (!live) return;
|
||||||
|
// A failure to *look* is not a failure to verify, and must not be shown
|
||||||
|
// as one: a dropped connection is not a bad signature.
|
||||||
|
setState({ status: "done", report: { crypto: { kind: "unsupported", reason: "other", detail: (err as Error).message }, warnings: [] } });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [email, accountId]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pin(address: string, entry: SignerPin): void {
|
||||||
|
const { settings, update } = useSettings.getState();
|
||||||
|
const next = { ...settings.knownSigners, [address]: entry };
|
||||||
|
const keys = Object.keys(next);
|
||||||
|
if (keys.length > MAX_PINS) {
|
||||||
|
const oldest = keys.sort((a, b) => (next[a]!.firstSeen < next[b]!.firstSeen ? -1 : 1)).slice(0, keys.length - MAX_PINS);
|
||||||
|
for (const k of oldest) delete next[k];
|
||||||
|
}
|
||||||
|
update({ knownSigners: next });
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Checking an S/MIME signature, and deciding what may honestly be said about it.
|
||||||
|
*
|
||||||
|
* Two questions are kept deliberately apart, because conflating them is how
|
||||||
|
* signature UI becomes a lie:
|
||||||
|
*
|
||||||
|
* 1. **Did this signature verify?** Pure arithmetic. Either the bytes hash to
|
||||||
|
* what the signature says they hash to, or they do not.
|
||||||
|
* 2. **Does that mean anything?** Much weaker. The certificate travels inside
|
||||||
|
* the message, so anyone can self-sign as anyone: on its own, a verified
|
||||||
|
* signature proves only that whoever wrote the message also held the key
|
||||||
|
* in the certificate attached to it.
|
||||||
|
*
|
||||||
|
* What makes the second question worth asking at all is remembering the answer.
|
||||||
|
* The first signed message from an address pins that certificate's fingerprint;
|
||||||
|
* later ones are compared against it. That is trust on first use, and it is a
|
||||||
|
* genuinely useful thing to tell somebody -- "the same signer as every time
|
||||||
|
* before", or, much more loudly, "this is not the signer you saw before" --
|
||||||
|
* without a certificate authority anywhere in the picture.
|
||||||
|
*
|
||||||
|
* So nothing here ever renders the bare word "verified". The caller is given
|
||||||
|
* the crypto result and the trust judgement separately, and has to say both.
|
||||||
|
*/
|
||||||
|
import { parseSignedData, type SignerInfo } from "./cms";
|
||||||
|
import { decodeTransfer, findPart, parseMime, toCanonicalCrlf, type MimePart } from "./mime";
|
||||||
|
import { certCovers, parseCertificate, type Certificate } from "./x509";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a signature could not be checked, or did not hold.
|
||||||
|
*
|
||||||
|
* A code rather than a sentence, because the sentence has to be translated and
|
||||||
|
* this file is deliberately free of anything to do with the interface. Only
|
||||||
|
* `other` carries prose, and that prose is a parser's complaint about a
|
||||||
|
* malformed structure -- technical by nature, and shown as detail beside a
|
||||||
|
* translated headline rather than as the headline itself.
|
||||||
|
*/
|
||||||
|
export type Reason =
|
||||||
|
| "openpgp"
|
||||||
|
| "rsa-pss"
|
||||||
|
| "no-certificate"
|
||||||
|
| "not-signed-properly"
|
||||||
|
| "digest-mismatch"
|
||||||
|
| "signature-mismatch"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
/** What the signature itself established, before any question of trust. */
|
||||||
|
export type Crypto =
|
||||||
|
| { kind: "none" }
|
||||||
|
| { kind: "unsupported"; reason: Reason; detail?: string }
|
||||||
|
| { kind: "broken"; reason: Reason; detail?: string }
|
||||||
|
| { kind: "intact"; cert: Certificate; signer: SignerInfo };
|
||||||
|
|
||||||
|
/** What remembering previous signers adds to it. */
|
||||||
|
export type Trust = "first-seen" | "same-as-before" | "changed";
|
||||||
|
|
||||||
|
export type Warning = "address-mismatch" | "certificate-expired" | "certificate-not-yet-valid";
|
||||||
|
|
||||||
|
export interface KnownSigner {
|
||||||
|
fingerprint: string;
|
||||||
|
/** Who the certificate said it was, kept so a change can be described. */
|
||||||
|
name: string;
|
||||||
|
/** ISO date this fingerprint was first pinned. */
|
||||||
|
firstSeen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureReport {
|
||||||
|
crypto: Crypto;
|
||||||
|
trust?: Trust;
|
||||||
|
previous?: KnownSigner;
|
||||||
|
warnings: Warning[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PKCS7_SIGNATURE = new Set(["application/pkcs7-signature", "application/x-pkcs7-signature"]);
|
||||||
|
|
||||||
|
/** Whether a raw message even claims to be signed — cheap, for deciding to look further. */
|
||||||
|
export function looksSigned(root: MimePart): boolean {
|
||||||
|
return Boolean(findPart(root, (p) => p.contentType === "multipart/signed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the signature on a raw RFC822 message.
|
||||||
|
*
|
||||||
|
* Answers only the arithmetic question. Whether the certificate has anything to
|
||||||
|
* do with the sender is `judge`'s business, and keeping the two apart is what
|
||||||
|
* lets the interesting cases be tested without staging a message for each.
|
||||||
|
*/
|
||||||
|
export async function verifyMessage(raw: Uint8Array): Promise<Crypto> {
|
||||||
|
let root: MimePart;
|
||||||
|
try {
|
||||||
|
root = parseMime(raw);
|
||||||
|
} catch (err) {
|
||||||
|
return { kind: "unsupported", reason: "other", detail: (err as Error).message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedPart = findPart(root, (p) => p.contentType === "multipart/signed");
|
||||||
|
if (!signedPart) return { kind: "none" };
|
||||||
|
if (signedPart.parts.length < 2) return { kind: "unsupported", reason: "not-signed-properly" };
|
||||||
|
|
||||||
|
const [content, signature] = signedPart.parts as [MimePart, MimePart];
|
||||||
|
if (!PKCS7_SIGNATURE.has(signature.contentType)) {
|
||||||
|
// OpenPGP lands here, and says so rather than pretending not to understand.
|
||||||
|
if (signature.contentType === "application/pgp-signature") {
|
||||||
|
return { kind: "unsupported", reason: "openpgp" };
|
||||||
|
}
|
||||||
|
return { kind: "unsupported", reason: "other", detail: signature.contentType };
|
||||||
|
}
|
||||||
|
|
||||||
|
let signed;
|
||||||
|
try {
|
||||||
|
signed = parseSignedData(decodeTransfer(signature));
|
||||||
|
} catch (err) {
|
||||||
|
return { kind: "unsupported", reason: "other", detail: (err as Error).message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signer = signed.signers[0]!;
|
||||||
|
if (signer.signature === "rsa-pss") {
|
||||||
|
// Refused rather than attempted. The salt length lives in parameters this
|
||||||
|
// does not read, and guessing it wrong fails a good signature -- which
|
||||||
|
// would be reported as "does not verify", a far worse thing to say than
|
||||||
|
// "cannot check".
|
||||||
|
return { kind: "unsupported", reason: "rsa-pss" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The signature covers the first part exactly as it arrived, headers and all,
|
||||||
|
// in canonical CRLF form.
|
||||||
|
const covered = toCanonicalCrlf(content.raw);
|
||||||
|
const digest = new Uint8Array(await crypto.subtle.digest(signer.digest, covered.slice().buffer as ArrayBuffer));
|
||||||
|
if (!sameBytes(digest, signer.messageDigest)) {
|
||||||
|
return { kind: "broken", reason: "digest-mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const certs = await Promise.all(
|
||||||
|
signed.certificates.map(async (der) => {
|
||||||
|
try {
|
||||||
|
return await parseCertificate(der);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const usable = certs.filter((c): c is Certificate => c !== null);
|
||||||
|
if (usable.length === 0) return { kind: "unsupported", reason: "no-certificate" };
|
||||||
|
|
||||||
|
// Prefer the certificate the signer names, but fall back to trying each in
|
||||||
|
// turn: what settles it is which key the signature verifies under, and that
|
||||||
|
// is a stronger test than matching an issuer string.
|
||||||
|
const named = usable.find((c) => signer.issuerDer && sameBytes(c.issuerDer, signer.issuerDer) && c.serial === signer.serial);
|
||||||
|
for (const cert of named ? [named, ...usable.filter((c) => c !== named)] : usable) {
|
||||||
|
if (await signatureHolds(cert, signer)) return { kind: "intact", cert, signer };
|
||||||
|
}
|
||||||
|
return { kind: "broken", reason: "signature-mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signatureHolds(cert: Certificate, signer: SignerInfo): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const spki = cert.spki.slice().buffer as ArrayBuffer;
|
||||||
|
const data = signer.signedAttrs.slice().buffer as ArrayBuffer;
|
||||||
|
if (cert.publicKey.kind === "rsa") {
|
||||||
|
const key = await crypto.subtle.importKey("spki", spki, { name: "RSASSA-PKCS1-v1_5", hash: signer.digest }, false, ["verify"]);
|
||||||
|
return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signer.value.slice().buffer as ArrayBuffer, data);
|
||||||
|
}
|
||||||
|
const key = await crypto.subtle.importKey("spki", spki, { name: "ECDSA", namedCurve: cert.publicKey.namedCurve }, false, ["verify"]);
|
||||||
|
const raw = ecdsaDerToRaw(signer.value, cert.publicKey.namedCurve);
|
||||||
|
if (!raw) return false;
|
||||||
|
return await crypto.subtle.verify({ name: "ECDSA", hash: signer.digest }, key, raw.slice().buffer as ArrayBuffer, data);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ECDSA signatures arrive as a DER SEQUENCE of two INTEGERs; WebCrypto wants
|
||||||
|
* r and s as fixed-width bytes, concatenated. Getting the width from the curve
|
||||||
|
* rather than from the integers matters: a leading zero byte is stripped in
|
||||||
|
* DER, so r and s are frequently different lengths and neither is the answer.
|
||||||
|
*/
|
||||||
|
export function ecdsaDerToRaw(der: Uint8Array, curve: "P-256" | "P-384" | "P-521"): Uint8Array | null {
|
||||||
|
const size = curve === "P-256" ? 32 : curve === "P-384" ? 48 : 66;
|
||||||
|
try {
|
||||||
|
if (der[0] !== 0x30) return null;
|
||||||
|
let i = 2;
|
||||||
|
if (der[1]! > 0x80) i = 2 + (der[1]! & 0x7f);
|
||||||
|
const out = new Uint8Array(size * 2);
|
||||||
|
for (const slot of [0, 1]) {
|
||||||
|
if (der[i] !== 0x02) return null;
|
||||||
|
const len = der[i + 1]!;
|
||||||
|
let start = i + 2;
|
||||||
|
let n = len;
|
||||||
|
while (n > 0 && der[start] === 0x00) {
|
||||||
|
start++;
|
||||||
|
n--;
|
||||||
|
}
|
||||||
|
if (n > size) return null;
|
||||||
|
out.set(der.subarray(start, start + n), slot * size + (size - n));
|
||||||
|
i = i + 2 + len;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a crypto result plus what we remember into something sayable.
|
||||||
|
*
|
||||||
|
* Pure, and separate from both the network and the store, so the interesting
|
||||||
|
* cases -- a changed signer, a certificate for the wrong address -- are
|
||||||
|
* ordinary function calls to test rather than scenarios to stage.
|
||||||
|
*/
|
||||||
|
export function judge(crypto: Crypto, fromAddress: string, known: KnownSigner | undefined, now = new Date()): SignatureReport {
|
||||||
|
if (crypto.kind !== "intact") return { crypto, warnings: [] };
|
||||||
|
|
||||||
|
const warnings: Warning[] = [];
|
||||||
|
if (!certCovers(crypto.cert, fromAddress)) warnings.push("address-mismatch");
|
||||||
|
if (crypto.cert.notAfter < now) warnings.push("certificate-expired");
|
||||||
|
if (crypto.cert.notBefore > now) warnings.push("certificate-not-yet-valid");
|
||||||
|
|
||||||
|
const trust: Trust = !known ? "first-seen" : known.fingerprint === crypto.cert.fingerprint ? "same-as-before" : "changed";
|
||||||
|
return { crypto, trust, previous: trust === "changed" ? known : undefined, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this result should be pinned as the signer for an address.
|
||||||
|
*
|
||||||
|
* Only a clean first sighting is remembered. Pinning a certificate that does
|
||||||
|
* not name the sender, or one already expired, would write the anomaly into the
|
||||||
|
* baseline and make every later message agree with it.
|
||||||
|
*/
|
||||||
|
export function shouldRemember(report: SignatureReport): boolean {
|
||||||
|
return report.crypto.kind === "intact" && report.trust === "first-seen" && report.warnings.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* The parts of an X.509 certificate a signed message needs, and no more.
|
||||||
|
*
|
||||||
|
* Not a validator. Nothing here decides whether a certificate is trustworthy —
|
||||||
|
* it reads what the certificate says about itself, and what it says is only
|
||||||
|
* ever as good as whoever issued it. The trust decision lives one level up, in
|
||||||
|
* `verify.ts`, and is deliberately a small and honest one.
|
||||||
|
*/
|
||||||
|
import { at, children, DerError, expect, hex, integerHex, oid, parse, TAG, text, time, type Asn1 } from "./der";
|
||||||
|
|
||||||
|
/** Relative distinguished-name attributes worth naming. */
|
||||||
|
const OID = {
|
||||||
|
commonName: "2.5.4.3",
|
||||||
|
emailAddress: "1.2.840.113549.1.9.1",
|
||||||
|
organization: "2.5.4.10",
|
||||||
|
subjectAltName: "2.5.29.17",
|
||||||
|
rsaEncryption: "1.2.840.113549.1.1.1",
|
||||||
|
ecPublicKey: "1.2.840.10045.2.1",
|
||||||
|
curveP256: "1.2.840.10045.3.1.7",
|
||||||
|
curveP384: "1.3.132.0.34",
|
||||||
|
curveP521: "1.3.132.0.35",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface Certificate {
|
||||||
|
/** SHA-256 over the whole DER, lowercase hex. What TOFU remembers. */
|
||||||
|
fingerprint: string;
|
||||||
|
serial: string;
|
||||||
|
subject: { commonName?: string; organization?: string; emailAddress?: string };
|
||||||
|
issuer: { commonName?: string; organization?: string };
|
||||||
|
/** rfc822Name entries from the subjectAltName extension, plus the subject's emailAddress. */
|
||||||
|
emails: string[];
|
||||||
|
notBefore: Date;
|
||||||
|
notAfter: Date;
|
||||||
|
/** SubjectPublicKeyInfo, DER, ready for crypto.subtle.importKey("spki", …). */
|
||||||
|
spki: Uint8Array;
|
||||||
|
publicKey: { kind: "rsa" } | { kind: "ec"; namedCurve: "P-256" | "P-384" | "P-521" };
|
||||||
|
/** Whole DER, kept so a signer can be matched and a fingerprint recomputed. */
|
||||||
|
der: Uint8Array;
|
||||||
|
/** Issuer name and serial, the pair a SignerInfo usually identifies a certificate by. */
|
||||||
|
issuerDer: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read one certificate from its DER encoding. */
|
||||||
|
export async function parseCertificate(der: Uint8Array): Promise<Certificate> {
|
||||||
|
const cert = parse(der);
|
||||||
|
const top = children(expect(cert, TAG.sequence, "a Certificate"));
|
||||||
|
const tbs = children(expect(at(top, 0, "tbsCertificate"), TAG.sequence, "a tbsCertificate"));
|
||||||
|
|
||||||
|
// tbsCertificate ::= [0] version, serial, signature, issuer, validity,
|
||||||
|
// subject, subjectPublicKeyInfo, … — version is optional and explicit, so
|
||||||
|
// everything after it shifts by one when it is absent.
|
||||||
|
let i = 0;
|
||||||
|
if (tbs[0]?.cls === 2 && tbs[0].tag === 0) i = 1;
|
||||||
|
|
||||||
|
const serial = integerHex(at(tbs, i++, "serialNumber"));
|
||||||
|
i++; // signature AlgorithmIdentifier: the outer one, not used here
|
||||||
|
const issuerNode = at(tbs, i++, "issuer");
|
||||||
|
const validity = children(expect(at(tbs, i++, "validity"), TAG.sequence, "a validity"));
|
||||||
|
const subjectNode = at(tbs, i++, "subject");
|
||||||
|
const spkiNode = at(tbs, i++, "subjectPublicKeyInfo");
|
||||||
|
|
||||||
|
const notBefore = time(at(validity, 0, "notBefore"));
|
||||||
|
const notAfter = time(at(validity, 1, "notAfter"));
|
||||||
|
|
||||||
|
const subject = readName(subjectNode);
|
||||||
|
const issuer = readName(issuerNode);
|
||||||
|
|
||||||
|
const emails = new Set<string>();
|
||||||
|
if (subject.emailAddress) emails.add(subject.emailAddress.toLowerCase());
|
||||||
|
for (const e of subjectAltEmails(tbs.slice(i))) emails.add(e.toLowerCase());
|
||||||
|
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", der.slice().buffer as ArrayBuffer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fingerprint: hex(new Uint8Array(digest)),
|
||||||
|
serial,
|
||||||
|
subject,
|
||||||
|
issuer: { commonName: issuer.commonName, organization: issuer.organization },
|
||||||
|
emails: [...emails],
|
||||||
|
notBefore,
|
||||||
|
notAfter,
|
||||||
|
spki: spkiNode.bytes,
|
||||||
|
publicKey: readKeyKind(spkiNode),
|
||||||
|
der,
|
||||||
|
issuerDer: issuerNode.bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Name is a sequence of RDN sets; the last occurrence of an attribute wins. */
|
||||||
|
function readName(node: Asn1): { commonName?: string; organization?: string; emailAddress?: string } {
|
||||||
|
const out: { commonName?: string; organization?: string; emailAddress?: string } = {};
|
||||||
|
for (const rdn of children(node)) {
|
||||||
|
for (const attr of children(rdn)) {
|
||||||
|
const kv = children(attr);
|
||||||
|
if (kv.length < 2) continue;
|
||||||
|
const key = oid(at(kv, 0, "an attribute type"));
|
||||||
|
const value = text(at(kv, 1, "an attribute value"));
|
||||||
|
if (key === OID.commonName) out.commonName = value;
|
||||||
|
else if (key === OID.organization) out.organization = value;
|
||||||
|
else if (key === OID.emailAddress) out.emailAddress = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* rfc822Name entries from subjectAltName.
|
||||||
|
*
|
||||||
|
* This is where a modern certificate puts the address; the subject's
|
||||||
|
* emailAddress attribute is the older place and is often absent. Reading only
|
||||||
|
* one of the two means failing to match the sender on half the certificates in
|
||||||
|
* circulation.
|
||||||
|
*/
|
||||||
|
function subjectAltEmails(rest: Asn1[]): string[] {
|
||||||
|
// Extensions are [3] EXPLICIT SEQUENCE OF Extension.
|
||||||
|
const ext = rest.find((n) => n.cls === 2 && n.tag === 3);
|
||||||
|
if (!ext) return [];
|
||||||
|
const seq = children(ext)[0];
|
||||||
|
if (!seq) return [];
|
||||||
|
for (const extension of children(seq)) {
|
||||||
|
const parts = children(extension);
|
||||||
|
if (parts.length < 2) continue;
|
||||||
|
if (oid(at(parts, 0, "an extension id")) !== OID.subjectAltName) continue;
|
||||||
|
// The value is an OCTET STRING wrapping the real structure. Critical flag
|
||||||
|
// may sit between the two, so take the last part rather than index 1.
|
||||||
|
const wrapper = parts[parts.length - 1]!;
|
||||||
|
try {
|
||||||
|
const names = children(parse(wrapper.content));
|
||||||
|
// GeneralName ::= CHOICE, and rfc822Name is [1] IMPLICIT IA5String.
|
||||||
|
return names.filter((n) => n.cls === 2 && n.tag === 1).map((n) => new TextDecoder().decode(n.content));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readKeyKind(spki: Asn1): Certificate["publicKey"] {
|
||||||
|
const parts = children(spki);
|
||||||
|
const alg = children(at(parts, 0, "an algorithm identifier"));
|
||||||
|
const algOid = oid(at(alg, 0, "an algorithm"));
|
||||||
|
if (algOid === OID.rsaEncryption) return { kind: "rsa" };
|
||||||
|
if (algOid === OID.ecPublicKey) {
|
||||||
|
const curve = alg[1] ? oid(alg[1]) : "";
|
||||||
|
if (curve === OID.curveP256) return { kind: "ec", namedCurve: "P-256" };
|
||||||
|
if (curve === OID.curveP384) return { kind: "ec", namedCurve: "P-384" };
|
||||||
|
if (curve === OID.curveP521) return { kind: "ec", namedCurve: "P-521" };
|
||||||
|
throw new DerError(`Unsupported elliptic curve ${curve}.`);
|
||||||
|
}
|
||||||
|
throw new DerError(`Unsupported public key algorithm ${algOid}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the certificate names this address, case-insensitively. */
|
||||||
|
export function certCovers(cert: Certificate, address: string): boolean {
|
||||||
|
const a = address.trim().toLowerCase();
|
||||||
|
return cert.emails.includes(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short, readable name for the human holding the certificate. */
|
||||||
|
export function certDisplayName(cert: Certificate): string {
|
||||||
|
return cert.subject.commonName || cert.subject.emailAddress || cert.emails[0] || cert.subject.organization || cert.serial;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fingerprint in the grouped form people actually compare by eye. */
|
||||||
|
export function formatFingerprint(fp: string): string {
|
||||||
|
return (fp.match(/.{2}/g) ?? [fp]).join(":").toUpperCase();
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* The name of the cache the service worker keeps.
|
||||||
|
*
|
||||||
|
* It is `VERSION` in `web/public/sw.js`, and the worker is not built from this
|
||||||
|
* source -- it is copied to `dist` verbatim, so nothing checks that the two
|
||||||
|
* agree. They have to: the worker uses that cache to leave things for a tab to
|
||||||
|
* collect when there was no tab to hand them to, and a name that has drifted
|
||||||
|
* does not fail, it silently finds nothing. A push verification never
|
||||||
|
* completes; a share arrives at an empty composer.
|
||||||
|
*
|
||||||
|
* One copy on this side of the line, so at least the app cannot disagree with
|
||||||
|
* itself.
|
||||||
|
*/
|
||||||
|
export const SW_CACHE_NAME = "ihasmail-v2";
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* What the service worker cannot work out for itself.
|
||||||
|
*
|
||||||
|
* The worker can act on mail — see the note on `jmap()` in sw.js — but it
|
||||||
|
* cannot read a catalogue or a store. It is plain JavaScript copied into the
|
||||||
|
* build, outside the bundle, with no i18n and no idea which mailbox is the
|
||||||
|
* archive. Both of those are things a tab knows and can simply write down.
|
||||||
|
*
|
||||||
|
* So the app leaves a short briefing in the same cache it uses for every other
|
||||||
|
* handoff, and the worker reads it when a notification arrives. Where there is
|
||||||
|
* none, the worker offers no actions at all rather than guessing: an untitled
|
||||||
|
* button that files mail somewhere is worse than a notification you have to
|
||||||
|
* open.
|
||||||
|
*
|
||||||
|
* That means the actions appear once ihasmail has been opened since the worker
|
||||||
|
* was installed, which is the same condition background notifications already
|
||||||
|
* carry — a push subscription has to be renewed from a tab too.
|
||||||
|
*/
|
||||||
|
import { withBase } from "./basePath";
|
||||||
|
import { SW_CACHE_NAME } from "./swCache";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
|
||||||
|
export const FACTS_KEY = "/ihasmail-worker-facts";
|
||||||
|
|
||||||
|
export interface WorkerFacts {
|
||||||
|
/** The account the notifications are about. */
|
||||||
|
accountId: string;
|
||||||
|
/** Where Archive files to; null where the account has no archive folder. */
|
||||||
|
archiveId: string | null;
|
||||||
|
/** The worker's own user-visible text, in the language this tab is in. */
|
||||||
|
strings: {
|
||||||
|
newMail: string;
|
||||||
|
newMessage: string;
|
||||||
|
noSubject: string;
|
||||||
|
archive: string;
|
||||||
|
markRead: string;
|
||||||
|
failed: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the briefing.
|
||||||
|
*
|
||||||
|
* Called again whenever what is in it could have changed — the language, the
|
||||||
|
* account, the archive folder — because it is what the worker will still be
|
||||||
|
* reading in a week's time. Rewriting it is one cache put; there is nothing to
|
||||||
|
* gain by working out whether it differs.
|
||||||
|
*/
|
||||||
|
export async function publishWorkerFacts(accountId: string | null, archiveId: string | null): Promise<void> {
|
||||||
|
if (typeof caches === "undefined" || !accountId) return;
|
||||||
|
const facts: WorkerFacts = {
|
||||||
|
accountId,
|
||||||
|
archiveId,
|
||||||
|
strings: {
|
||||||
|
newMail: t("New mail"),
|
||||||
|
newMessage: t("New message"),
|
||||||
|
noSubject: t("(no subject)"),
|
||||||
|
archive: t("Archive"),
|
||||||
|
markRead: t("Mark as read"),
|
||||||
|
failed: t("Could not do that — open ihasmail and try again"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(SW_CACHE_NAME);
|
||||||
|
await cache.put(withBase(FACTS_KEY), new Response(JSON.stringify(facts), { headers: { "content-type": "application/json" } }));
|
||||||
|
} catch {
|
||||||
|
/* no cache storage: the worker falls back to a notification with no actions */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
import { CAP } from "@/jmap/client";
|
import { CAP } from "@/jmap/client";
|
||||||
import { withBase } from "./basePath";
|
import { withBase } from "./basePath";
|
||||||
|
import { SW_CACHE_NAME } from "./swCache";
|
||||||
import { isDeviceTrusted } from "@/lib/storage";
|
import { isDeviceTrusted } from "@/lib/storage";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
@@ -48,7 +49,7 @@ export function listenForVerification(): void {
|
|||||||
/** Pick up a code that arrived while no tab was open. */
|
/** Pick up a code that arrived while no tab was open. */
|
||||||
async function collectStoredVerification(): Promise<void> {
|
async function collectStoredVerification(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cache = await caches.open("ihasmail-v2");
|
const cache = await caches.open(SW_CACHE_NAME);
|
||||||
// The same absolute key the worker writes. Relative would be resolved
|
// The same absolute key the worker writes. Relative would be resolved
|
||||||
// against this document's URL, which is a different place on every route.
|
// against this document's URL, which is a different place on every route.
|
||||||
const key = withBase("/ihasmail-push-verification");
|
const key = withBase("/ihasmail-push-verification");
|
||||||
|
|||||||
+275
-13
@@ -55,6 +55,146 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
strings: {
|
||||||
|
// ── Administration: domains ────────────────────────────────────
|
||||||
|
"Domains": "Domains",
|
||||||
|
"By hand": "Manuell",
|
||||||
|
"Signing": "Signiert",
|
||||||
|
"Published, not signing yet": "Veröffentlicht, signiert noch nicht",
|
||||||
|
"Retiring": "Wird ausgemustert",
|
||||||
|
"Retired": "Ausgemustert",
|
||||||
|
"This domain no longer exists. Someone may have removed it.": "Diese Domain existiert nicht mehr. Möglicherweise hat sie jemand entfernt.",
|
||||||
|
"That doesn't look like a domain name, such as example.com.": "Das sieht nicht nach einem Domainnamen wie example.com aus.",
|
||||||
|
"Added {name}. Its DNS records are ready to copy.": "{name} hinzugefügt. Die DNS-Einträge können kopiert werden.",
|
||||||
|
"Saved {name}": "{name} gespeichert",
|
||||||
|
"Add domain": "Domain hinzufügen",
|
||||||
|
"Added {date}": "Hinzugefügt am {date}",
|
||||||
|
"This domain is disabled on the server.": "Diese Domain ist auf dem Server deaktiviert.",
|
||||||
|
"Your role lets you view domains but not change them.": "Ihre Rolle erlaubt es, Domains anzusehen, aber nicht zu ändern.",
|
||||||
|
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Neue Domains signieren ihre E-Mails mit DKIM-Schlüsseln, die der Server erstellt und wechselt. Die DNS-Einträge erscheinen hier, sobald die Domain hinzugefügt ist.",
|
||||||
|
"Other names": "Weitere Namen",
|
||||||
|
"Delivery": "Zustellung",
|
||||||
|
"Catch-all address": "Sammeladresse",
|
||||||
|
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "E-Mails an eine Adresse, die auf dieser Domain niemand hat, werden hierher zugestellt. Leer lassen, um sie abzulehnen.",
|
||||||
|
"Plus addressing": "Plus-Adressierung",
|
||||||
|
"Set by a custom rule on the server.": "Durch eine eigene Regel auf dem Server festgelegt.",
|
||||||
|
"Mail to name+anything@ is delivered to name@.": "E-Mails an name+beliebig@ werden an name@ zugestellt.",
|
||||||
|
"DNS records": "DNS-Einträge",
|
||||||
|
"Published automatically through {provider}.": "Automatisch über {provider} veröffentlicht.",
|
||||||
|
"Published automatically by the server.": "Automatisch vom Server veröffentlicht.",
|
||||||
|
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Tragen Sie diese dort ein, wo das DNS dieser Domain verwaltet wird. Bis dahin werden E-Mails weder zugestellt noch als vertrauenswürdig eingestuft.",
|
||||||
|
"Copy {type} record for {name}": "{type}-Eintrag für {name} kopieren",
|
||||||
|
"Copy value": "Wert kopieren",
|
||||||
|
"Copied the zone file": "Zonendatei kopiert",
|
||||||
|
"Copy all as a zone file": "Alles als Zonendatei kopieren",
|
||||||
|
"The server returned no records for this domain.": "Der Server hat für diese Domain keine Einträge geliefert.",
|
||||||
|
"DKIM keys": "DKIM-Schlüssel",
|
||||||
|
"The server creates and rotates these keys itself.": "Der Server erstellt und wechselt diese Schlüssel selbst.",
|
||||||
|
"These keys are managed by hand on the server.": "Diese Schlüssel werden auf dem Server manuell verwaltet.",
|
||||||
|
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "Keine DKIM-Schlüssel: E-Mails von dieser Domain werden nicht signiert und landen eher im Spam.",
|
||||||
|
"Managed by the server": "Vom Server verwaltet",
|
||||||
|
"Certificate": "Zertifikat",
|
||||||
|
"Another name for this domain": "Weiterer Name für diese Domain",
|
||||||
|
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "E-Mails an dieselbe Adresse unter einem dieser Namen erreichen dasselbe Konto. Änderungen gelten nach dem Speichern.",
|
||||||
|
"Its DKIM keys have to be removed first, and your role can't remove them.": "Zuerst müssen die DKIM-Schlüssel entfernt werden, und Ihre Rolle darf sie nicht entfernen.",
|
||||||
|
"The server stops accepting mail for this domain.": "Der Server nimmt keine E-Mails mehr für diese Domain an.",
|
||||||
|
"Remove domain…": "Domain entfernen…",
|
||||||
|
"Remove {name}?": "{name} entfernen?",
|
||||||
|
"Removed {name}": "{name} entfernt",
|
||||||
|
"The server kept the domain: it is still used by {things}.": "Der Server hat die Domain behalten: Sie wird noch verwendet von {things}.",
|
||||||
|
"Remove domain": "Domain entfernen",
|
||||||
|
"The server stops accepting mail for this domain. This can't be undone.": "Der Server nimmt keine E-Mails mehr für diese Domain an. Dies kann nicht rückgängig gemacht werden.",
|
||||||
|
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Wo Ihre Adressen liegen, und die DNS-Einträge, damit E-Mails ankommen und als vertrauenswürdig gelten.",
|
||||||
|
"Search domains": "Domains durchsuchen",
|
||||||
|
"No domains match": "Keine passenden Domains",
|
||||||
|
"No domains yet": "Noch keine Domains",
|
||||||
|
"DKIM": "DKIM",
|
||||||
|
"Tenant": "Mandant",
|
||||||
|
"Disabled": "Deaktiviert",
|
||||||
|
"also {names}": "auch {names}",
|
||||||
|
"The server did not say whether the domain was created.": "Der Server hat nicht mitgeteilt, ob die Domain angelegt wurde.",
|
||||||
|
// ── Administration: refusals ───────────────────────────────────
|
||||||
|
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Das ist kein gültiger Domainname. Verwenden Sie einen Namen wie example.com mit einer echten Top-Level-Domain.",
|
||||||
|
"That isn't a valid email address. Use a full address, such as [email protected].": "Das ist keine gültige E-Mail-Adresse. Verwenden Sie eine vollständige Adresse wie [email protected].",
|
||||||
|
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Das ist keine gültige Adresse. Verwenden Sie vor dem @ Buchstaben, Ziffern, Punkte, Bindestriche oder Unterstriche.",
|
||||||
|
"That isn't a valid host name or IP address.": "Das ist kein gültiger Hostname und keine gültige IP-Adresse.",
|
||||||
|
"A required value was left empty.": "Ein erforderlicher Wert wurde leer gelassen.",
|
||||||
|
"Administration is turned off on this installation.": "Die Verwaltung ist in dieser Installation deaktiviert.",
|
||||||
|
"The mail server could not carry out the request ({code}).": "Der Mailserver konnte die Anfrage nicht ausführen ({code}).",
|
||||||
|
"You can't give an account permissions your own role doesn't have.": "Sie können einem Konto keine Berechtigungen geben, die Ihre eigene Rolle nicht hat.",
|
||||||
|
"This account signs in through an external directory, so its password can't be set here.": "Dieses Konto meldet sich über ein externes Verzeichnis an, daher kann sein Passwort hier nicht festgelegt werden.",
|
||||||
|
"The server's licence allows no more accounts.": "Die Lizenz des Servers erlaubt keine weiteren Konten.",
|
||||||
|
"That domain name is already in use on this server, as a domain or another domain's other name.": "Dieser Domainname wird auf diesem Server bereits verwendet – als Domain oder als weiterer Name einer anderen Domain.",
|
||||||
|
"Your organisation has reached the number of domains it is allowed.": "Ihre Organisation hat die Anzahl der erlaubten Domains erreicht.",
|
||||||
|
"That is more than the mail server accepts in one change.": "Das ist mehr, als der Mailserver in einer Änderung annimmt.",
|
||||||
|
"The mail server rejected one of the values. Check what you entered and try again.": "Der Mailserver hat einen der Werte abgelehnt. Prüfen Sie Ihre Eingaben und versuchen Sie es erneut.",
|
||||||
|
"The mail server refused the change ({code}).": "Der Mailserver hat die Änderung abgelehnt ({code}).",
|
||||||
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
|
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Nur auf einem Gerät, das Sie als Ihr eigenes markiert haben. Melden Sie sich erneut an und setzen Sie das Häkchen bei „Das ist mein eigenes Gerät“.",
|
||||||
|
"Change your own password in {settings}.": "Ihr eigenes Passwort ändern Sie unter {settings}.",
|
||||||
|
"Administration": "Verwaltung",
|
||||||
|
"Directory": "Verzeichnis",
|
||||||
|
"User": "Benutzer",
|
||||||
|
"Administrator": "Administrator",
|
||||||
|
"Custom role": "Eigene Rolle",
|
||||||
|
"New account": "Neues Konto",
|
||||||
|
"The people who sign in to mail on the domains you manage.": "Die Personen, die sich auf den von Ihnen verwalteten Domains bei ihrer E-Mail anmelden.",
|
||||||
|
"Search by name or address": "Nach Name oder Adresse suchen",
|
||||||
|
"Search accounts": "Konten durchsuchen",
|
||||||
|
"No accounts match": "Keine passenden Konten",
|
||||||
|
"No accounts yet": "Noch keine Konten",
|
||||||
|
"Nothing on your domains matches “{query}”.": "Auf Ihren Domains passt nichts zu „{query}“.",
|
||||||
|
"Open {address}": "{address} öffnen",
|
||||||
|
"{from}–{to} of {total}": "{from}–{to} von {total}",
|
||||||
|
"Previous page": "Vorherige Seite",
|
||||||
|
"Next page": "Nächste Seite",
|
||||||
|
"Storage": "Speicher",
|
||||||
|
"Groups": "Gruppen",
|
||||||
|
"{used} · no limit": "{used} · ohne Begrenzung",
|
||||||
|
"Profile": "Profil",
|
||||||
|
"Domain": "Domain",
|
||||||
|
"No domains are available to create an account on.": "Es gibt keine Domain, auf der ein Konto angelegt werden kann.",
|
||||||
|
"Sign-in": "Anmeldung",
|
||||||
|
"Other addresses": "Weitere Adressen",
|
||||||
|
"Not in any group": "In keiner Gruppe",
|
||||||
|
"You can't change your own role.": "Sie können Ihre eigene Rolle nicht ändern.",
|
||||||
|
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Angeboten werden nur Rollen, deren Berechtigungen Sie selbst besitzen. Bei einem Konto innerhalb eines Mandanten bedeutet Administrator: Administrator dieses Mandanten.",
|
||||||
|
"Limit in GB": "Begrenzung in GB",
|
||||||
|
"No limit": "Ohne Begrenzung",
|
||||||
|
"This account has permissions yours doesn't, so you can view it but not change it.": "Dieses Konto hat Berechtigungen, die Ihres nicht hat. Sie können es ansehen, aber nicht ändern.",
|
||||||
|
"Your role lets you view accounts but not change them.": "Ihre Rolle erlaubt es, Konten anzusehen, aber nicht zu ändern.",
|
||||||
|
"This account has permissions yours doesn't.": "Dieses Konto hat Berechtigungen, die Ihres nicht hat.",
|
||||||
|
"You can't delete the account you're signed in with.": "Das Konto, mit dem Sie angemeldet sind, können Sie nicht löschen.",
|
||||||
|
"Create account": "Konto anlegen",
|
||||||
|
"An account needs an address.": "Ein Konto braucht eine Adresse.",
|
||||||
|
"Created {address}": "{address} angelegt",
|
||||||
|
"Saved {address}": "{address} gespeichert",
|
||||||
|
"Generate a password": "Passwort erzeugen",
|
||||||
|
"Pass it on some way other than email to this address.": "Geben Sie es nicht per E-Mail an diese Adresse weiter.",
|
||||||
|
"This account has no password. It may sign in through a directory or single sign-on.": "Dieses Konto hat kein Passwort. Möglicherweise meldet es sich über ein Verzeichnis oder Single Sign-on an.",
|
||||||
|
"Set a new password…": "Neues Passwort festlegen…",
|
||||||
|
"{name} will be signed out of every app and device using the old password.": "{name} wird in allen Apps und auf allen Geräten abgemeldet, die das alte Passwort verwenden.",
|
||||||
|
"New password set for {address}": "Neues Passwort für {address} festgelegt",
|
||||||
|
"Set password": "Passwort festlegen",
|
||||||
|
"Remove {address}": "{address} entfernen",
|
||||||
|
"New address": "Neue Adresse",
|
||||||
|
"another name": "anderer Name",
|
||||||
|
"Mail to these addresses is delivered to this account. Changes apply when you save.": "E-Mails an diese Adressen werden diesem Konto zugestellt. Änderungen gelten nach dem Speichern.",
|
||||||
|
"Deletes the mailbox and everything in it.": "Löscht das Postfach und alles darin.",
|
||||||
|
"Delete account…": "Konto löschen…",
|
||||||
|
"Delete {address}?": "{address} löschen?",
|
||||||
|
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Dadurch werden die E-Mails, Kalender, Kontakte und Dateien dieses Kontos gelöscht. Der Server entfernt sie im Hintergrund, und es kann nicht rückgängig gemacht werden.",
|
||||||
|
"Type {address} to confirm": "Zur Bestätigung {address} eingeben",
|
||||||
|
"Delete account": "Konto löschen",
|
||||||
|
"Deleted {address}": "{address} gelöscht",
|
||||||
|
"The server did not say whether the account was created.": "Der Server hat nicht mitgeteilt, ob das Konto angelegt wurde.",
|
||||||
|
"The mail server refused this. Your role may not allow it.": "Der Mailserver hat dies abgelehnt. Ihre Rolle erlaubt es möglicherweise nicht.",
|
||||||
|
"That address is already in use on this server, as an account, a list or an alias.": "Diese Adresse wird auf diesem Server bereits verwendet – als Konto, Liste oder Alias.",
|
||||||
|
"One of the chosen domain, role or group can't be used for this account.": "Die gewählte Domain, Rolle oder Gruppe kann für dieses Konto nicht verwendet werden.",
|
||||||
|
"Your organisation has reached the number of accounts it is allowed.": "Ihre Organisation hat die Anzahl der erlaubten Konten erreicht.",
|
||||||
|
"Something still depends on this, so the server kept it.": "Etwas hängt noch davon ab, daher hat der Server es behalten.",
|
||||||
|
"This account no longer exists. Someone may have deleted it.": "Dieses Konto existiert nicht mehr. Möglicherweise hat es jemand gelöscht.",
|
||||||
|
"The password was not accepted: {reason}": "Das Passwort wurde nicht akzeptiert: {reason}",
|
||||||
|
"The password was not accepted.": "Das Passwort wurde nicht akzeptiert.",
|
||||||
"Go to folder…": "Zu Ordner springen…",
|
"Go to folder…": "Zu Ordner springen…",
|
||||||
"Set for everyone here. You cannot change this.": "Für alle hier festgelegt. Sie können dies nicht ändern.",
|
"Set for everyone here. You cannot change this.": "Für alle hier festgelegt. Sie können dies nicht ändern.",
|
||||||
"Export iCAL file": "iCAL-Datei exportieren",
|
"Export iCAL file": "iCAL-Datei exportieren",
|
||||||
@@ -327,7 +467,6 @@ export const catalog: Catalog = {
|
|||||||
"Busy": "Gebucht",
|
"Busy": "Gebucht",
|
||||||
"Free/busy": "Frei/Gebucht",
|
"Free/busy": "Frei/Gebucht",
|
||||||
"Show as": "Anzeigen als",
|
"Show as": "Anzeigen als",
|
||||||
"Availability on {date}": "Verfügbarkeit am {date}",
|
|
||||||
"Count all events as busy": "Alle Termine als gebucht zählen",
|
"Count all events as busy": "Alle Termine als gebucht zählen",
|
||||||
"Only events I'm attending": "Nur Termine, an denen ich teilnehme",
|
"Only events I'm attending": "Nur Termine, an denen ich teilnehme",
|
||||||
"Don't include in availability": "Nicht in die Verfügbarkeit einbeziehen",
|
"Don't include in availability": "Nicht in die Verfügbarkeit einbeziehen",
|
||||||
@@ -366,7 +505,6 @@ export const catalog: Catalog = {
|
|||||||
"New address book": "Neues Adressbuch",
|
"New address book": "Neues Adressbuch",
|
||||||
"No address books yet.": "Noch keine Adressbücher.",
|
"No address books yet.": "Noch keine Adressbücher.",
|
||||||
"Choose from address books": "Aus Adressbüchern wählen",
|
"Choose from address books": "Aus Adressbüchern wählen",
|
||||||
"Import vCard": "vCard importieren",
|
|
||||||
"Export all contacts": "Alle Kontakte exportieren",
|
"Export all contacts": "Alle Kontakte exportieren",
|
||||||
"Export address book": "Dieses Adressbuch exportieren",
|
"Export address book": "Dieses Adressbuch exportieren",
|
||||||
"Import contacts…": "Kontakte importieren…",
|
"Import contacts…": "Kontakte importieren…",
|
||||||
@@ -437,7 +575,6 @@ export const catalog: Catalog = {
|
|||||||
"Make ihasmail yours.": "Machen Sie ihasmail zu Ihrem.",
|
"Make ihasmail yours.": "Machen Sie ihasmail zu Ihrem.",
|
||||||
"Reading": "Lesen",
|
"Reading": "Lesen",
|
||||||
"Reading pane": "Lesebereich",
|
"Reading pane": "Lesebereich",
|
||||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "Verhalten beim Lesen, Senden und in der Liste. Die Einstellungen werden in diesem Browser gespeichert.",
|
|
||||||
"Right of the list": "Rechts von der Liste",
|
"Right of the list": "Rechts von der Liste",
|
||||||
"Below the list": "Unter der Liste",
|
"Below the list": "Unter der Liste",
|
||||||
"Hidden (open full width)": "Ausgeblendet (in voller Breite öffnen)",
|
"Hidden (open full width)": "Ausgeblendet (in voller Breite öffnen)",
|
||||||
@@ -518,6 +655,8 @@ export const catalog: Catalog = {
|
|||||||
"Show labels in the sidebar": "Labels in der Seitenleiste anzeigen",
|
"Show labels in the sidebar": "Labels in der Seitenleiste anzeigen",
|
||||||
"Collapse sidebar to icons": "Seitenleiste auf Symbole verkleinern",
|
"Collapse sidebar to icons": "Seitenleiste auf Symbole verkleinern",
|
||||||
"Apply the theme to messages too": "Design auch auf Nachrichten anwenden",
|
"Apply the theme to messages too": "Design auch auf Nachrichten anwenden",
|
||||||
|
"Apply it even to mail that styles itself": "Auch auf Mails anwenden, die sich selbst gestalten",
|
||||||
|
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Fast jede Werbe- oder Beleg-Mail setzt irgendwo eine Farbe, deshalb lässt die Einstellung darüber nahezu alle davon auf einer weißen Karte. Mit dieser Option wird das Design über die Farben des Absenders gelegt: Hintergründe, auf denen die Nachricht liegt, entfallen, während Schaltflächen und farbige Banner erhalten bleiben, damit ihr Text lesbar bleibt. Manche Mail übersteht das nicht unbeschadet – deshalb ist es eine eigene Einstellung.",
|
||||||
"Swiping": "Wischgesten",
|
"Swiping": "Wischgesten",
|
||||||
"Swipe left": "Nach links wischen",
|
"Swipe left": "Nach links wischen",
|
||||||
"Swipe right": "Nach rechts wischen",
|
"Swipe right": "Nach rechts wischen",
|
||||||
@@ -712,8 +851,6 @@ export const catalog: Catalog = {
|
|||||||
"Manage labels": "Labels verwalten",
|
"Manage labels": "Labels verwalten",
|
||||||
"Create “{name}”": "„{name}“ erstellen",
|
"Create “{name}”": "„{name}“ erstellen",
|
||||||
"Type a name to create your first label.": "Geben Sie einen Namen ein, um Ihr erstes Label zu erstellen.",
|
"Type a name to create your first label.": "Geben Sie einen Namen ein, um Ihr erstes Label zu erstellen.",
|
||||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Labels sind IMAP-Schlüsselwörter, die in Ihren Nachrichten gespeichert werden und daher mit anderen Clients synchronisiert werden. Namen und Farben bleiben in diesem Browser.",
|
|
||||||
"PDF": "PDF",
|
|
||||||
"Large attachments may be rejected by some servers": "Große Anhänge werden von manchen Servern abgelehnt",
|
"Large attachments may be rejected by some servers": "Große Anhänge werden von manchen Servern abgelehnt",
|
||||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Bilder werden in Ihren Dateien (Ordner „ihasmail“) gespeichert und beim Senden eingebettet.",
|
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Bilder werden in Ihren Dateien (Ordner „ihasmail“) gespeichert und beim Senden eingebettet.",
|
||||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Vielen Dank für Ihre Nachricht. Ich bin bis … abwesend und melde mich nach meiner Rückkehr.",
|
"Thanks for your message. I'm away until … and will reply when I'm back.": "Vielen Dank für Ihre Nachricht. Ich bin bis … abwesend und melde mich nach meiner Rückkehr.",
|
||||||
@@ -754,6 +891,7 @@ export const catalog: Catalog = {
|
|||||||
"Open the Mail view to see all shortcuts.": "Öffnen Sie die E-Mail-Ansicht, um alle Tastenkürzel zu sehen.",
|
"Open the Mail view to see all shortcuts.": "Öffnen Sie die E-Mail-Ansicht, um alle Tastenkürzel zu sehen.",
|
||||||
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Tastenkürzel im Gmail-Stil sind immer aktiv. Drücken Sie überall {key}, um diese Liste zu sehen.",
|
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Tastenkürzel im Gmail-Stil sind immer aktiv. Drücken Sie überall {key}, um diese Liste zu sehen.",
|
||||||
"Select a conversation to read it here · Press {key} for shortcuts": "Wählen Sie eine Konversation, um sie hier zu lesen · {key} für Tastenkürzel",
|
"Select a conversation to read it here · Press {key} for shortcuts": "Wählen Sie eine Konversation, um sie hier zu lesen · {key} für Tastenkürzel",
|
||||||
|
"Select a message to read it here · Press {key} for shortcuts": "Wählen Sie eine Nachricht, um sie hier zu lesen · {key} für Tastenkürzel",
|
||||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tipp: Drücken Sie {key} auf einer Konversation, um Labels zu vergeben. Suchen Sie mit {operator}.",
|
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tipp: Drücken Sie {key} auf einer Konversation, um Labels zu vergeben. Suchen Sie mit {operator}.",
|
||||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Eine schnelle, freundliche Open-Source-Webmail für {server}, auf JMAP aufgebaut.",
|
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Eine schnelle, freundliche Open-Source-Webmail für {server}, auf JMAP aufgebaut.",
|
||||||
"Defaults for the calendar views and new events.": "Vorgaben für die Kalenderansichten und neue Termine.",
|
"Defaults for the calendar views and new events.": "Vorgaben für die Kalenderansichten und neue Termine.",
|
||||||
@@ -832,19 +970,15 @@ export const catalog: Catalog = {
|
|||||||
"Nothing": "Nichts",
|
"Nothing": "Nichts",
|
||||||
|
|
||||||
"No conversation selected": "Keine Konversation ausgewählt",
|
"No conversation selected": "Keine Konversation ausgewählt",
|
||||||
|
"No message selected": "Keine Nachricht ausgewählt",
|
||||||
"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 ────────────────────────────────────────────────
|
||||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} ist die Farbpalette von {site} und das, womit ein neues Konto startet. Es ist ein dunkles Design und zählt daher überall dort als dunkel, wo das eine Rolle spielt; die Akzentfarbe unten wirkt weiterhin darauf.",
|
|
||||||
"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.",
|
"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.",
|
||||||
|
|
||||||
// ── 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
|
||||||
// field names in the message, not words.
|
// field names in the message, not words.
|
||||||
"Tuesday": "Dienstag",
|
|
||||||
"Wednesday": "Mittwoch",
|
|
||||||
"Thursday": "Donnerstag",
|
|
||||||
"Friday": "Freitag",
|
|
||||||
"Later today": "Später heute",
|
"Later today": "Später heute",
|
||||||
"Tomorrow morning": "Morgen früh",
|
"Tomorrow morning": "Morgen früh",
|
||||||
"Tomorrow afternoon": "Morgen Nachmittag",
|
"Tomorrow afternoon": "Morgen Nachmittag",
|
||||||
@@ -878,6 +1012,8 @@ export const catalog: Catalog = {
|
|||||||
"folder\u0004Junk Mail": "Spam",
|
"folder\u0004Junk Mail": "Spam",
|
||||||
"folder\u0004Important": "Wichtig",
|
"folder\u0004Important": "Wichtig",
|
||||||
"folder\u0004All mail": "Alle Nachrichten",
|
"folder\u0004All mail": "Alle Nachrichten",
|
||||||
|
"share sheet\u0004Share": "Teilen",
|
||||||
|
"share sheet\u0004Share…": "Teilen…",
|
||||||
"folder": "Ordner",
|
"folder": "Ordner",
|
||||||
"“{name}” moved into “{parent}”": "„{name}“ wurde nach „{parent}“ verschoben",
|
"“{name}” moved into “{parent}”": "„{name}“ wurde nach „{parent}“ verschoben",
|
||||||
"“{name}” moved to the top level": "„{name}“ wurde auf die oberste Ebene verschoben",
|
"“{name}” moved to the top level": "„{name}“ wurde auf die oberste Ebene verschoben",
|
||||||
@@ -888,6 +1024,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Neue Nachricht",
|
"New message": "Neue Nachricht",
|
||||||
|
"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",
|
||||||
"Sending…": "Wird gesendet…",
|
"Sending…": "Wird gesendet…",
|
||||||
"Saving…": "Wird gespeichert…",
|
"Saving…": "Wird gespeichert…",
|
||||||
"Error": "Fehler",
|
"Error": "Fehler",
|
||||||
@@ -928,6 +1066,7 @@ export const catalog: Catalog = {
|
|||||||
"Could not copy the address": "Die Adresse konnte nicht kopiert werden",
|
"Could not copy the address": "Die Adresse konnte nicht kopiert werden",
|
||||||
"Could not empty folder: {error}": "Ordner konnte nicht geleert werden: {error}",
|
"Could not empty folder: {error}": "Ordner konnte nicht geleert werden: {error}",
|
||||||
"Could not load source: {error}": "Quelltext konnte nicht geladen werden: {error}",
|
"Could not load source: {error}": "Quelltext konnte nicht geladen werden: {error}",
|
||||||
|
"Could not share: {error}": "Teilen nicht möglich: {error}",
|
||||||
"Could not mark as read: {error}": "Konnte nicht als gelesen markiert werden: {error}",
|
"Could not mark as read: {error}": "Konnte nicht als gelesen markiert werden: {error}",
|
||||||
"Could not save draft: {error}": "Entwurf konnte nicht gespeichert werden: {error}",
|
"Could not save draft: {error}": "Entwurf konnte nicht gespeichert werden: {error}",
|
||||||
"Could not save filter: {error}": "Filter konnte nicht gespeichert werden: {error}",
|
"Could not save filter: {error}": "Filter konnte nicht gespeichert werden: {error}",
|
||||||
@@ -1153,7 +1292,7 @@ export const catalog: Catalog = {
|
|||||||
"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.": "Zählt Personen statt Kopfzeilen: eine Adresse in An und neun in Cc ergeben eine Nachricht an zehn. Erfasst ein Allen-Antworten auf einen langen Thread.",
|
"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.": "Zählt Personen statt Kopfzeilen: eine Adresse in An und neun in Cc ergeben eine Nachricht an zehn. Erfasst ein Allen-Antworten auf einen langen Thread.",
|
||||||
"Date received": "Empfangsdatum",
|
"Date received": "Empfangsdatum",
|
||||||
"Date sent": "Sendedatum",
|
"Date sent": "Sendedatum",
|
||||||
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula, Gruvbox, Rosé Pine und Tokyo Night sind das Werk ihrer eigenen Projekte und werden unter der MIT-Lizenz verwendet; die Abstufungen zwischen ihren veröffentlichten Farben sind davon abgeleitet, und jede einzelne wird auf Kontrast geprüft. Die Akzentfarbe unten gilt weiterhin über jeder von ihnen.",
|
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "Paletten, die nach einem anderen Projekt benannt sind, stammen von diesem Projekt und werden unter dessen eigener Lizenz verwendet; die Abstufungen zwischen den veröffentlichten Farben sind abgeleitet, und jede davon wird auf Kontrast geprüft. Die Akzentfarbe unten gilt weiterhin über jeder von ihnen.",
|
||||||
"Earlier": "Früher",
|
"Earlier": "Früher",
|
||||||
"Every folder": "Jeder Ordner",
|
"Every folder": "Jeder Ordner",
|
||||||
"Everyone addressed will receive this.": "Alle Adressierten erhalten dies.",
|
"Everyone addressed will receive this.": "Alle Adressierten erhalten dies.",
|
||||||
@@ -1238,6 +1377,14 @@ export const catalog: Catalog = {
|
|||||||
"Throw away your changes?": "Änderungen verwerfen?",
|
"Throw away your changes?": "Änderungen verwerfen?",
|
||||||
"Today, in your date format": "Heute, in Ihrem Datumsformat",
|
"Today, in your date format": "Heute, in Ihrem Datumsformat",
|
||||||
"Unread first": "Ungelesene zuerst",
|
"Unread first": "Ungelesene zuerst",
|
||||||
|
"Read first": "Gelesene zuerst",
|
||||||
|
"Unstarred first": "Nicht markierte zuerst",
|
||||||
|
"Smallest first": "Kleinste zuerst",
|
||||||
|
"Z to A": "Z bis A",
|
||||||
|
"A to Z": "A bis Z",
|
||||||
|
"It reads {shown} but goes to {actual}.": "Angezeigt wird {shown}, geöffnet wird aber {actual}.",
|
||||||
|
"The full address is {href}.": "Die vollständige Adresse lautet {href}.",
|
||||||
|
"This message came from {domain}, which is outside your organisation.": "Diese Nachricht kam von {domain} und damit von außerhalb Ihrer Organisation.",
|
||||||
"Unsaved changes": "Nicht gespeicherte Änderungen",
|
"Unsaved changes": "Nicht gespeicherte Änderungen",
|
||||||
"View as": "Anzeigen als",
|
"View as": "Anzeigen als",
|
||||||
"Warnings": "Warnungen",
|
"Warnings": "Warnungen",
|
||||||
@@ -1282,8 +1429,119 @@ export const catalog: Catalog = {
|
|||||||
"This message was sent automatically, so no read receipt is offered.": "Diese Nachricht wurde automatisch versendet, daher wird keine Lesebestätigung angeboten.",
|
"This message was sent automatically, so no read receipt is offered.": "Diese Nachricht wurde automatisch versendet, daher wird keine Lesebestätigung angeboten.",
|
||||||
"This server will not hold a message longer than {span}.": "Dieser Server hält eine Nachricht nicht länger als {span} zurück.",
|
"This server will not hold a message longer than {span}.": "Dieser Server hält eine Nachricht nicht länger als {span} zurück.",
|
||||||
"Upload failed": "Hochladen fehlgeschlagen",
|
"Upload failed": "Hochladen fehlgeschlagen",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
"Every {n} {frequency}": "Alle {n} {frequency}",
|
||||||
|
"Monthly": "Monatlich",
|
||||||
|
"Monthly on day {days}": "Monatlich am Tag {days}",
|
||||||
|
"Monthly on the {ordinal} {weekday}": "Monatlich am {ordinal} {weekday}",
|
||||||
|
"Monthly on {weekday}": "Monatlich am {weekday}",
|
||||||
|
"Weekly": "Wöchentlich",
|
||||||
|
"Weekly on {days}": "Wöchentlich am {days}",
|
||||||
|
"add {flag}": "{flag} hinzufügen",
|
||||||
|
"always": "immer",
|
||||||
|
"body contains \"{value}\"": "Text enthält \"{value}\"",
|
||||||
|
"body does not contain \"{value}\"": "Text enthält nicht \"{value}\"",
|
||||||
|
"delete it": "löschen",
|
||||||
|
"fifth": "fünften",
|
||||||
|
"first": "ersten",
|
||||||
|
"forward to {address}": "weiterleiten an {address}",
|
||||||
|
"fourth": "vierten",
|
||||||
|
"keep it": "behalten",
|
||||||
|
"last": "letzten",
|
||||||
|
"mark it read": "als gelesen markieren",
|
||||||
|
"move to {folder}": "verschieben nach {folder}",
|
||||||
|
"reject it": "abweisen",
|
||||||
|
"remove {flag}": "{flag} entfernen",
|
||||||
|
"second": "zweiten",
|
||||||
|
"size is over {n} KB": "Größe über {n} KB",
|
||||||
|
"size is under {n} KB": "Größe unter {n} KB",
|
||||||
|
"star it": "markieren",
|
||||||
|
"stop": "anhalten",
|
||||||
|
"third": "dritten",
|
||||||
|
"{header} address {op} \"{value}\"": "{header}-Adresse {op} \"{value}\"",
|
||||||
|
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
|
||||||
|
"{rule}, until {date}": "{rule}, bis {date}",
|
||||||
|
"{tests} → {actions}": "{tests} → {actions}",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"This cannot be undone.": "Dies kann nicht rückgängig gemacht werden.",
|
||||||
|
"Some could not be deleted: {error}": "Einige konnten nicht gelöscht werden: {error}",
|
||||||
|
"It was not deleted": "Der Kontakt wurde nicht gelöscht",
|
||||||
|
"Empty address book": "Dieses Adressbuch leeren",
|
||||||
|
"There is nothing in it to delete": "Es ist nichts darin zum Löschen",
|
||||||
|
"Empty “{name}”?": "„{name}“ leeren?",
|
||||||
|
"Delete them": "Alle löschen",
|
||||||
|
"Nothing was deleted": "Es wurde nichts gelöscht",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "Zertifikat gilt für",
|
||||||
|
"Details": "Details",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Frühere Nachrichten von dieser Adresse wurden von {previous} signiert. Diese ist von {current} signiert.",
|
||||||
|
"Fingerprint": "Fingerabdruck",
|
||||||
|
"Hide details": "Details ausblenden",
|
||||||
|
"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 uses a signature algorithm ihasmail cannot check yet.": "Sie verwendet ein Signaturverfahren, das ihasmail 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.",
|
||||||
|
"Previous fingerprint": "Vorheriger Fingerabdruck",
|
||||||
|
"Signed at": "Signiert am",
|
||||||
|
"Signed by {name} — the same signer as before.": "Signiert von {name} — derselbe Unterzeichner wie zuvor.",
|
||||||
|
"Signed by {name}, seen here for the first time.": "Signiert von {name}, hier zum ersten Mal gesehen.",
|
||||||
|
"Signer": "Unterzeichner",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Das kann ein erneuertes Zertifikat bedeuten, und es kann jemand anderes sein. Fragen Sie auf einem anderen Weg nach, bevor Sie dem vertrauen.",
|
||||||
|
"The certificate has expired.": "Das Zertifikat ist abgelaufen.",
|
||||||
|
"The certificate is not valid yet.": "Das Zertifikat ist noch nicht gültig.",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Die Nachricht stimmt nicht mit dem Signierten überein — sie wurde nach dem Signieren verändert oder unterwegs beschädigt.",
|
||||||
|
"The signature carries no certificate that can be read.": "Die Signatur enthält kein lesbares Zertifikat.",
|
||||||
|
"The signature could not be read.": "Die Signatur konnte nicht gelesen werden.",
|
||||||
|
"The signature does not match the certificate sent with it.": "Die Signatur passt nicht zum mitgesendeten Zertifikat.",
|
||||||
|
"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 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 signature does not check out.": "Diese Signatur stimmt nicht.",
|
||||||
|
"Valid until": "Gültig bis",
|
||||||
|
"a different certificate": "einem anderen Zertifikat",
|
||||||
|
"an unnamed signer": "einem unbenannten Unterzeichner",
|
||||||
|
"as claimed by the signer": "laut Angabe des Unterzeichners",
|
||||||
|
"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.",
|
||||||
|
"itself, or an issuer it does not name": "sich selbst, oder einem nicht genannten Aussteller",
|
||||||
|
"no address": "keine Adresse",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── 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." },
|
||||||
|
"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." },
|
||||||
|
"{n} domains": { one: "{n} Domain", other: "{n} Domains" },
|
||||||
|
"{n} mailing lists": { one: "{n} Mailingliste", other: "{n} Mailinglisten" },
|
||||||
|
"{n} DKIM keys": { one: "{n} DKIM-Schlüssel", other: "{n} DKIM-Schlüssel" },
|
||||||
|
"{n} other items": { one: "{n} weiteres Objekt", other: "{n} weitere Objekte" },
|
||||||
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
"{n} accounts": { one: "{n} Konto", other: "{n} Konten" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Delete {n} items": { one: "{n} Element löschen", other: "{n} Elemente löschen" },
|
||||||
|
"Delete {n} items?": { one: "{n} Element löschen?", other: "{n} Elemente löschen?" },
|
||||||
|
"Move {n} items": { one: "{n} Element verschieben", other: "{n} Elemente verschieben" },
|
||||||
|
"Move {n} items…": { one: "{n} Element verschieben…", other: "{n} Elemente verschieben…" },
|
||||||
|
"The event runs {n} days longer than this shows.": { one: "Der Termin dauert {n} Tag länger, als hier angezeigt wird.", other: "Der Termin dauert {n} Tage länger, als hier angezeigt wird." },
|
||||||
|
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} Gast ist nicht auf diesem Server, daher gibt es dafür keine Frei/Gebucht-Informationen.", other: "{n} Gäste sind nicht auf diesem Server, daher gibt es dafür keine Frei/Gebucht-Informationen." },
|
||||||
|
"{n} items selected": { one: "{n} Element ausgewählt", other: "{n} Elemente ausgewählt" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Every {n} days": { one: "Jeden Tag", other: "Alle {n} Tage" },
|
||||||
|
"Every {n} months": { one: "Jeden Monat", other: "Alle {n} Monate" },
|
||||||
|
"Every {n} months on day {days}": { one: "Jeden Monat am Tag {days}", other: "Alle {n} Monate am Tag {days}" },
|
||||||
|
"Every {n} months on the {ordinal} {weekday}": { one: "Jeden Monat am {ordinal} {weekday}", other: "Alle {n} Monate am {ordinal} {weekday}" },
|
||||||
|
"Every {n} months on {weekday}": { one: "Jeden Monat am {weekday}", other: "Alle {n} Monate am {weekday}" },
|
||||||
|
"Every {n} weeks": { one: "Jede Woche", other: "Alle {n} Wochen" },
|
||||||
|
"Every {n} weeks on {days}": { one: "Jede Woche am {days}", other: "Alle {n} Wochen am {days}" },
|
||||||
|
"Every {n} years": { one: "Jedes Jahr", other: "Alle {n} Jahre" },
|
||||||
|
"{rule}, {n} times": { one: "{rule}, {n}-mal", other: "{rule}, {n}-mal" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
"Move {n} messages to Trash?": { one: "{n} Nachricht in den Papierkorb verschieben?", other: "{n} Nachrichten in den Papierkorb verschieben?" },
|
"Move {n} messages to Trash?": { one: "{n} Nachricht in den Papierkorb verschieben?", other: "{n} Nachrichten in den Papierkorb verschieben?" },
|
||||||
"{n} days": { one: "{n} Tag", other: "{n} Tage" },
|
"{n} days": { one: "{n} Tag", other: "{n} Tage" },
|
||||||
@@ -1295,8 +1553,7 @@ export const catalog: Catalog = {
|
|||||||
"Your administrator changed {n} settings": { one: "Ihre Administration hat {n} Einstellung geändert", other: "Ihre Administration hat {n} Einstellungen geändert" },
|
"Your administrator changed {n} settings": { one: "Ihre Administration hat {n} Einstellung geändert", other: "Ihre Administration hat {n} Einstellungen geändert" },
|
||||||
"Exported {n} events": { one: "{n} Termin exportiert", other: "{n} Termine exportiert" },
|
"Exported {n} events": { one: "{n} Termin exportiert", other: "{n} Termine exportiert" },
|
||||||
"Imported {n} events": { one: "{n} Termin importiert", other: "{n} Termine importiert" },
|
"Imported {n} events": { one: "{n} Termin importiert", other: "{n} Termine importiert" },
|
||||||
"Already here: {n} events, nothing imported": { one: "Bereits vorhanden: {n} Termin, nichts importiert", other: "Bereits vorhanden: {n} Termine, nichts importiert" },
|
"Updated {n} events, nothing new": { one: "{n} Termin aktualisiert, nichts Neues", other: "{n} Termine aktualisiert, nichts Neues" },
|
||||||
"{n} were already here": { one: "{n} war bereits vorhanden", other: "{n} waren bereits vorhanden" },
|
|
||||||
"{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" },
|
"{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" },
|
||||||
"{n} selected": { one: "{n} ausgewählt", other: "{n} ausgewählt" },
|
"{n} selected": { one: "{n} ausgewählt", other: "{n} ausgewählt" },
|
||||||
"{n} conversations": { one: "{n} Konversation", other: "{n} Konversationen" },
|
"{n} conversations": { one: "{n} Konversation", other: "{n} Konversationen" },
|
||||||
@@ -1315,5 +1572,10 @@ export const catalog: Catalog = {
|
|||||||
"Marked {n} messages as read": { one: "{n} Nachricht als gelesen markiert", other: "{n} Nachrichten als gelesen markiert" },
|
"Marked {n} messages as read": { one: "{n} Nachricht als gelesen markiert", other: "{n} Nachrichten als gelesen markiert" },
|
||||||
"in {n} folders": { one: "in {n} Ordner", other: "in {n} Ordnern" },
|
"in {n} folders": { one: "in {n} Ordner", other: "in {n} Ordnern" },
|
||||||
"Deleted {n} messages": { one: "{n} Nachricht gelöscht", other: "{n} Nachrichten gelöscht" },
|
"Deleted {n} messages": { one: "{n} Nachricht gelöscht", other: "{n} Nachrichten gelöscht" },
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"Delete {n} contacts?": { one: "{n} Kontakt löschen?", other: "{n} Kontakte löschen?" },
|
||||||
|
"Deleted {n} contacts": { one: "{n} Kontakt gelöscht", other: "{n} Kontakte gelöscht" },
|
||||||
|
"{n} contacts will be deleted. This cannot be undone.": { one: "{n} Kontakt wird gelöscht. Dies kann nicht rückgängig gemacht werden.", other: "{n} Kontakte werden gelöscht. Dies kann nicht rückgängig gemacht werden." },
|
||||||
|
"{n} were also in other address books and were only removed from this one": { one: "{n} Kontakt war auch in einem anderen Adressbuch und wurde nur aus diesem entfernt", other: "{n} Kontakte waren auch in anderen Adressbüchern und wurden nur aus diesem entfernt" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+275
-13
@@ -47,6 +47,146 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
strings: {
|
||||||
|
// ── Administration: domains ────────────────────────────────────
|
||||||
|
"Domains": "Dominios",
|
||||||
|
"By hand": "Manual",
|
||||||
|
"Signing": "Firmando",
|
||||||
|
"Published, not signing yet": "Publicada, aún no firma",
|
||||||
|
"Retiring": "Retirándose",
|
||||||
|
"Retired": "Retirada",
|
||||||
|
"This domain no longer exists. Someone may have removed it.": "Este dominio ya no existe. Puede que alguien lo haya quitado.",
|
||||||
|
"That doesn't look like a domain name, such as example.com.": "Eso no parece un nombre de dominio, como example.com.",
|
||||||
|
"Added {name}. Its DNS records are ready to copy.": "{name} añadido. Sus registros DNS están listos para copiar.",
|
||||||
|
"Saved {name}": "{name} guardado",
|
||||||
|
"Add domain": "Añadir dominio",
|
||||||
|
"Added {date}": "Añadido el {date}",
|
||||||
|
"This domain is disabled on the server.": "Este dominio está desactivado en el servidor.",
|
||||||
|
"Your role lets you view domains but not change them.": "Su rol le permite ver los dominios, pero no modificarlos.",
|
||||||
|
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Los dominios nuevos firman su correo con claves DKIM que el servidor crea y renueva. Sus registros DNS aparecen aquí una vez añadido.",
|
||||||
|
"Other names": "Otros nombres",
|
||||||
|
"Delivery": "Entrega",
|
||||||
|
"Catch-all address": "Dirección comodín",
|
||||||
|
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "El correo a una dirección que nadie tiene en este dominio se entrega aquí. Déjelo vacío para rechazarlo.",
|
||||||
|
"Plus addressing": "Subdirecciones con +",
|
||||||
|
"Set by a custom rule on the server.": "Definido por una regla personalizada en el servidor.",
|
||||||
|
"Mail to name+anything@ is delivered to name@.": "El correo a nombre+loquesea@ se entrega a nombre@.",
|
||||||
|
"DNS records": "Registros DNS",
|
||||||
|
"Published automatically through {provider}.": "Publicados automáticamente mediante {provider}.",
|
||||||
|
"Published automatically by the server.": "Publicados automáticamente por el servidor.",
|
||||||
|
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Añádalos donde esté alojado el DNS de este dominio. El correo no se entrega ni se considera fiable hasta que estén.",
|
||||||
|
"Copy {type} record for {name}": "Copiar el registro {type} de {name}",
|
||||||
|
"Copy value": "Copiar valor",
|
||||||
|
"Copied the zone file": "Archivo de zona copiado",
|
||||||
|
"Copy all as a zone file": "Copiar todo como archivo de zona",
|
||||||
|
"The server returned no records for this domain.": "El servidor no devolvió registros para este dominio.",
|
||||||
|
"DKIM keys": "Claves DKIM",
|
||||||
|
"The server creates and rotates these keys itself.": "El servidor crea y renueva estas claves por sí mismo.",
|
||||||
|
"These keys are managed by hand on the server.": "Estas claves se gestionan manualmente en el servidor.",
|
||||||
|
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "No hay claves DKIM, así que el correo de este dominio no se firma y es más probable que se marque como spam.",
|
||||||
|
"Managed by the server": "Gestionado por el servidor",
|
||||||
|
"Certificate": "Certificado",
|
||||||
|
"Another name for this domain": "Otro nombre para este dominio",
|
||||||
|
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "El correo a la misma dirección con cualquiera de estos nombres llega a la misma cuenta. Los cambios se aplican al guardar.",
|
||||||
|
"Its DKIM keys have to be removed first, and your role can't remove them.": "Primero hay que quitar sus claves DKIM, y su rol no puede quitarlas.",
|
||||||
|
"The server stops accepting mail for this domain.": "El servidor deja de aceptar correo para este dominio.",
|
||||||
|
"Remove domain…": "Quitar dominio…",
|
||||||
|
"Remove {name}?": "¿Quitar {name}?",
|
||||||
|
"Removed {name}": "{name} quitado",
|
||||||
|
"The server kept the domain: it is still used by {things}.": "El servidor ha conservado el dominio: todavía lo usa {things}.",
|
||||||
|
"Remove domain": "Quitar dominio",
|
||||||
|
"The server stops accepting mail for this domain. This can't be undone.": "El servidor deja de aceptar correo para este dominio. No se puede deshacer.",
|
||||||
|
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Dónde viven sus direcciones y los registros DNS que permiten que el correo llegue y sea de confianza.",
|
||||||
|
"Search domains": "Buscar dominios",
|
||||||
|
"No domains match": "Ningún dominio coincide",
|
||||||
|
"No domains yet": "Todavía no hay dominios",
|
||||||
|
"DKIM": "DKIM",
|
||||||
|
"Tenant": "Inquilino",
|
||||||
|
"Disabled": "Desactivado",
|
||||||
|
"also {names}": "también {names}",
|
||||||
|
"The server did not say whether the domain was created.": "El servidor no indicó si el dominio se creó.",
|
||||||
|
// ── Administration: refusals ───────────────────────────────────
|
||||||
|
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Ese no es un nombre de dominio válido. Use un nombre como example.com, con un dominio de nivel superior real.",
|
||||||
|
"That isn't a valid email address. Use a full address, such as [email protected].": "Esa no es una dirección de correo válida. Use una dirección completa, como [email protected].",
|
||||||
|
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Esa no es una dirección válida. Use letras, números, puntos, guiones o guiones bajos antes de la @.",
|
||||||
|
"That isn't a valid host name or IP address.": "Ese no es un nombre de host ni una dirección IP válidos.",
|
||||||
|
"A required value was left empty.": "Se dejó vacío un valor obligatorio.",
|
||||||
|
"Administration is turned off on this installation.": "La administración está desactivada en esta instalación.",
|
||||||
|
"The mail server could not carry out the request ({code}).": "El servidor de correo no pudo completar la solicitud ({code}).",
|
||||||
|
"You can't give an account permissions your own role doesn't have.": "No puede dar a una cuenta permisos que su propio rol no tiene.",
|
||||||
|
"This account signs in through an external directory, so its password can't be set here.": "Esta cuenta inicia sesión mediante un directorio externo, así que su contraseña no se puede establecer aquí.",
|
||||||
|
"The server's licence allows no more accounts.": "La licencia del servidor no permite más cuentas.",
|
||||||
|
"That domain name is already in use on this server, as a domain or another domain's other name.": "Ese nombre de dominio ya está en uso en este servidor, como dominio o como otro nombre de otro dominio.",
|
||||||
|
"Your organisation has reached the number of domains it is allowed.": "Su organización ha alcanzado el número de dominios permitido.",
|
||||||
|
"That is more than the mail server accepts in one change.": "Eso supera lo que el servidor de correo acepta en un solo cambio.",
|
||||||
|
"The mail server rejected one of the values. Check what you entered and try again.": "El servidor de correo ha rechazado uno de los valores. Revise lo que ha escrito e inténtelo de nuevo.",
|
||||||
|
"The mail server refused the change ({code}).": "El servidor de correo ha rechazado el cambio ({code}).",
|
||||||
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
|
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Solo en un dispositivo que haya marcado como suyo. Vuelva a iniciar sesión con «Este es mi propio dispositivo» marcado.",
|
||||||
|
"Change your own password in {settings}.": "Cambie su propia contraseña en {settings}.",
|
||||||
|
"Administration": "Administración",
|
||||||
|
"Directory": "Directorio",
|
||||||
|
"User": "Usuario",
|
||||||
|
"Administrator": "Administrador",
|
||||||
|
"Custom role": "Rol personalizado",
|
||||||
|
"New account": "Nueva cuenta",
|
||||||
|
"The people who sign in to mail on the domains you manage.": "Las personas que inician sesión en el correo en los dominios que usted administra.",
|
||||||
|
"Search by name or address": "Buscar por nombre o dirección",
|
||||||
|
"Search accounts": "Buscar cuentas",
|
||||||
|
"No accounts match": "Ninguna cuenta coincide",
|
||||||
|
"No accounts yet": "Todavía no hay cuentas",
|
||||||
|
"Nothing on your domains matches “{query}”.": "Nada en sus dominios coincide con «{query}».",
|
||||||
|
"Open {address}": "Abrir {address}",
|
||||||
|
"{from}–{to} of {total}": "{from}–{to} de {total}",
|
||||||
|
"Previous page": "Página anterior",
|
||||||
|
"Next page": "Página siguiente",
|
||||||
|
"Storage": "Almacenamiento",
|
||||||
|
"Groups": "Grupos",
|
||||||
|
"{used} · no limit": "{used} · sin límite",
|
||||||
|
"Profile": "Perfil",
|
||||||
|
"Domain": "Dominio",
|
||||||
|
"No domains are available to create an account on.": "No hay ningún dominio disponible en el que crear una cuenta.",
|
||||||
|
"Sign-in": "Inicio de sesión",
|
||||||
|
"Other addresses": "Otras direcciones",
|
||||||
|
"Not in any group": "No pertenece a ningún grupo",
|
||||||
|
"You can't change your own role.": "No puede cambiar su propio rol.",
|
||||||
|
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Solo se ofrecen los roles cuyos permisos usted tiene. En una cuenta dentro de un inquilino, Administrador significa administrador de ese inquilino.",
|
||||||
|
"Limit in GB": "Límite en GB",
|
||||||
|
"No limit": "Sin límite",
|
||||||
|
"This account has permissions yours doesn't, so you can view it but not change it.": "Esta cuenta tiene permisos que la suya no tiene, así que puede verla pero no modificarla.",
|
||||||
|
"Your role lets you view accounts but not change them.": "Su rol le permite ver las cuentas, pero no modificarlas.",
|
||||||
|
"This account has permissions yours doesn't.": "Esta cuenta tiene permisos que la suya no tiene.",
|
||||||
|
"You can't delete the account you're signed in with.": "No puede eliminar la cuenta con la que ha iniciado sesión.",
|
||||||
|
"Create account": "Crear cuenta",
|
||||||
|
"An account needs an address.": "Una cuenta necesita una dirección.",
|
||||||
|
"Created {address}": "{address} creada",
|
||||||
|
"Saved {address}": "{address} guardada",
|
||||||
|
"Generate a password": "Generar una contraseña",
|
||||||
|
"Pass it on some way other than email to this address.": "Comuníquela por otro medio que no sea un correo a esta dirección.",
|
||||||
|
"This account has no password. It may sign in through a directory or single sign-on.": "Esta cuenta no tiene contraseña. Puede que inicie sesión mediante un directorio o un inicio de sesión único.",
|
||||||
|
"Set a new password…": "Establecer una contraseña nueva…",
|
||||||
|
"{name} will be signed out of every app and device using the old password.": "Se cerrará la sesión de {name} en todas las aplicaciones y dispositivos que usen la contraseña anterior.",
|
||||||
|
"New password set for {address}": "Nueva contraseña establecida para {address}",
|
||||||
|
"Set password": "Establecer contraseña",
|
||||||
|
"Remove {address}": "Quitar {address}",
|
||||||
|
"New address": "Nueva dirección",
|
||||||
|
"another name": "otro nombre",
|
||||||
|
"Mail to these addresses is delivered to this account. Changes apply when you save.": "El correo enviado a estas direcciones se entrega a esta cuenta. Los cambios se aplican al guardar.",
|
||||||
|
"Deletes the mailbox and everything in it.": "Elimina el buzón y todo su contenido.",
|
||||||
|
"Delete account…": "Eliminar cuenta…",
|
||||||
|
"Delete {address}?": "¿Eliminar {address}?",
|
||||||
|
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Esto elimina el correo, los calendarios, los contactos y los archivos de esta cuenta. El servidor los borra en segundo plano y no se puede deshacer.",
|
||||||
|
"Type {address} to confirm": "Escriba {address} para confirmar",
|
||||||
|
"Delete account": "Eliminar cuenta",
|
||||||
|
"Deleted {address}": "{address} eliminada",
|
||||||
|
"The server did not say whether the account was created.": "El servidor no indicó si la cuenta se creó.",
|
||||||
|
"The mail server refused this. Your role may not allow it.": "El servidor de correo lo ha rechazado. Es posible que su rol no lo permita.",
|
||||||
|
"That address is already in use on this server, as an account, a list or an alias.": "Esa dirección ya está en uso en este servidor, como cuenta, lista o alias.",
|
||||||
|
"One of the chosen domain, role or group can't be used for this account.": "El dominio, el rol o el grupo elegido no se puede usar para esta cuenta.",
|
||||||
|
"Your organisation has reached the number of accounts it is allowed.": "Su organización ha alcanzado el número de cuentas permitido.",
|
||||||
|
"Something still depends on this, so the server kept it.": "Algo todavía depende de esto, así que el servidor lo ha conservado.",
|
||||||
|
"This account no longer exists. Someone may have deleted it.": "Esta cuenta ya no existe. Puede que alguien la haya eliminado.",
|
||||||
|
"The password was not accepted: {reason}": "La contraseña no se ha aceptado: {reason}",
|
||||||
|
"The password was not accepted.": "La contraseña no se ha aceptado.",
|
||||||
"Go to folder…": "Ir a la carpeta…",
|
"Go to folder…": "Ir a la carpeta…",
|
||||||
"Set for everyone here. You cannot change this.": "Definido para todos aquí. No puedes cambiarlo.",
|
"Set for everyone here. You cannot change this.": "Definido para todos aquí. No puedes cambiarlo.",
|
||||||
"Export iCAL file": "Exportar archivo iCAL",
|
"Export iCAL file": "Exportar archivo iCAL",
|
||||||
@@ -319,7 +459,6 @@ export const catalog: Catalog = {
|
|||||||
"Busy": "Ocupado",
|
"Busy": "Ocupado",
|
||||||
"Free/busy": "Disponibilidad",
|
"Free/busy": "Disponibilidad",
|
||||||
"Show as": "Mostrar como",
|
"Show as": "Mostrar como",
|
||||||
"Availability on {date}": "Disponibilidad el {date}",
|
|
||||||
"Count all events as busy": "Contar todos los eventos como ocupado",
|
"Count all events as busy": "Contar todos los eventos como ocupado",
|
||||||
"Only events I'm attending": "Solo los eventos a los que asisto",
|
"Only events I'm attending": "Solo los eventos a los que asisto",
|
||||||
"Don't include in availability": "No incluir en la disponibilidad",
|
"Don't include in availability": "No incluir en la disponibilidad",
|
||||||
@@ -358,7 +497,6 @@ export const catalog: Catalog = {
|
|||||||
"New address book": "Libreta de direcciones nueva",
|
"New address book": "Libreta de direcciones nueva",
|
||||||
"No address books yet.": "Aún no hay libretas de direcciones.",
|
"No address books yet.": "Aún no hay libretas de direcciones.",
|
||||||
"Choose from address books": "Elegir de las libretas de direcciones",
|
"Choose from address books": "Elegir de las libretas de direcciones",
|
||||||
"Import vCard": "Importar una vCard",
|
|
||||||
"Export all contacts": "Exportar todos los contactos",
|
"Export all contacts": "Exportar todos los contactos",
|
||||||
"Export address book": "Exportar esta libreta de direcciones",
|
"Export address book": "Exportar esta libreta de direcciones",
|
||||||
"Import contacts…": "Importar contactos…",
|
"Import contacts…": "Importar contactos…",
|
||||||
@@ -432,7 +570,6 @@ export const catalog: Catalog = {
|
|||||||
"Make ihasmail yours.": "Haga suyo ihasmail.",
|
"Make ihasmail yours.": "Haga suyo ihasmail.",
|
||||||
"Reading": "Lectura",
|
"Reading": "Lectura",
|
||||||
"Reading pane": "Panel de lectura",
|
"Reading pane": "Panel de lectura",
|
||||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "Comportamiento de lectura, envío y lista. La configuración se guarda en este navegador.",
|
|
||||||
"Right of the list": "A la derecha de la lista",
|
"Right of the list": "A la derecha de la lista",
|
||||||
"Below the list": "Debajo de la lista",
|
"Below the list": "Debajo de la lista",
|
||||||
"Hidden (open full width)": "Oculto (abrir a todo el ancho)",
|
"Hidden (open full width)": "Oculto (abrir a todo el ancho)",
|
||||||
@@ -475,10 +612,6 @@ export const catalog: Catalog = {
|
|||||||
"Time zone": "Zona horaria",
|
"Time zone": "Zona horaria",
|
||||||
"Week starts on": "La semana empieza el",
|
"Week starts on": "La semana empieza el",
|
||||||
"Monday": "Lunes",
|
"Monday": "Lunes",
|
||||||
"Tuesday": "Martes",
|
|
||||||
"Wednesday": "Miércoles",
|
|
||||||
"Thursday": "Jueves",
|
|
||||||
"Friday": "Viernes",
|
|
||||||
"Saturday": "Sábado",
|
"Saturday": "Sábado",
|
||||||
"Sunday": "Domingo",
|
"Sunday": "Domingo",
|
||||||
"12-hour clock (6:23 PM)": "Formato de 12 horas (6:23 PM)",
|
"12-hour clock (6:23 PM)": "Formato de 12 horas (6:23 PM)",
|
||||||
@@ -518,6 +651,8 @@ export const catalog: Catalog = {
|
|||||||
"Show labels in the sidebar": "Mostrar las etiquetas en la barra lateral",
|
"Show labels in the sidebar": "Mostrar las etiquetas en la barra lateral",
|
||||||
"Collapse sidebar to icons": "Reducir la barra lateral a iconos",
|
"Collapse sidebar to icons": "Reducir la barra lateral a iconos",
|
||||||
"Apply the theme to messages too": "Aplicar el tema también a los mensajes",
|
"Apply the theme to messages too": "Aplicar el tema también a los mensajes",
|
||||||
|
"Apply it even to mail that styles itself": "Aplicarlo incluso al correo que se da estilo propio",
|
||||||
|
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Casi todo el correo publicitario y de recibos define algún color, así que el ajuste anterior deja casi todo sobre una tarjeta blanca. Con esto activado, el tema se impone sobre los colores del remitente: se descartan los fondos sobre los que apoyó el mensaje, mientras que los botones y los banners de color se conservan para que su texto siga siendo legible. Algunos mensajes no sobrevivirán intactos, y por eso es un ajuste aparte.",
|
||||||
"Swiping": "Deslizamiento",
|
"Swiping": "Deslizamiento",
|
||||||
"Swipe left": "Deslizar a la izquierda",
|
"Swipe left": "Deslizar a la izquierda",
|
||||||
"Swipe right": "Deslizar a la derecha",
|
"Swipe right": "Deslizar a la derecha",
|
||||||
@@ -723,10 +858,8 @@ export const catalog: Catalog = {
|
|||||||
"Manage labels": "Gestionar las etiquetas",
|
"Manage labels": "Gestionar las etiquetas",
|
||||||
"Create “{name}”": "Crear «{name}»",
|
"Create “{name}”": "Crear «{name}»",
|
||||||
"Type a name to create your first label.": "Escriba un nombre para crear su primera etiqueta.",
|
"Type a name to create your first label.": "Escriba un nombre para crear su primera etiqueta.",
|
||||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Las etiquetas son palabras clave IMAP guardadas en sus mensajes, así que se sincronizan con otros clientes. Los nombres y colores se guardan en este navegador.",
|
|
||||||
"New label": "Etiqueta nueva",
|
"New label": "Etiqueta nueva",
|
||||||
"Delete label": "Eliminar la etiqueta",
|
"Delete label": "Eliminar la etiqueta",
|
||||||
"PDF": "PDF",
|
|
||||||
"Large attachments may be rejected by some servers": "Algunos servidores rechazan los adjuntos grandes",
|
"Large attachments may be rejected by some servers": "Algunos servidores rechazan los adjuntos grandes",
|
||||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Las imágenes se guardan en sus Archivos (carpeta «ihasmail») y se incrustan al enviar.",
|
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Las imágenes se guardan en sus Archivos (carpeta «ihasmail») y se incrustan al enviar.",
|
||||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Gracias por su mensaje. Estaré ausente hasta el … y le responderé a mi regreso.",
|
"Thanks for your message. I'm away until … and will reply when I'm back.": "Gracias por su mensaje. Estaré ausente hasta el … y le responderé a mi regreso.",
|
||||||
@@ -799,6 +932,8 @@ export const catalog: Catalog = {
|
|||||||
"folder\u0004Junk Mail": "Spam",
|
"folder\u0004Junk Mail": "Spam",
|
||||||
"folder\u0004Important": "Importante",
|
"folder\u0004Important": "Importante",
|
||||||
"folder\u0004All mail": "Todos los mensajes",
|
"folder\u0004All mail": "Todos los mensajes",
|
||||||
|
"share sheet\u0004Share": "Compartir",
|
||||||
|
"share sheet\u0004Share…": "Compartir…",
|
||||||
"folder": "carpeta",
|
"folder": "carpeta",
|
||||||
"“{name}” moved into “{parent}”": "«{name}» se ha movido a «{parent}»",
|
"“{name}” moved into “{parent}”": "«{name}» se ha movido a «{parent}»",
|
||||||
"“{name}” moved to the top level": "«{name}» se ha movido al nivel superior",
|
"“{name}” moved to the top level": "«{name}» se ha movido al nivel superior",
|
||||||
@@ -807,6 +942,7 @@ export const catalog: Catalog = {
|
|||||||
"Rename folder": "Cambiar el nombre de la carpeta",
|
"Rename folder": "Cambiar el nombre de la carpeta",
|
||||||
"Search: {query}": "Búsqueda: {query}",
|
"Search: {query}": "Búsqueda: {query}",
|
||||||
"No conversation selected": "Ninguna conversación seleccionada",
|
"No conversation selected": "Ninguna conversación seleccionada",
|
||||||
|
"No message selected": "Ningún mensaje seleccionado",
|
||||||
"Drop here for the top level": "Suelte aquí para el nivel superior",
|
"Drop here for the top level": "Suelte aquí para el nivel superior",
|
||||||
|
|
||||||
// ── Longer prose ───────────────────────────────────────────────────
|
// ── Longer prose ───────────────────────────────────────────────────
|
||||||
@@ -815,6 +951,7 @@ export const catalog: Catalog = {
|
|||||||
"Open the Mail view to see all shortcuts.": "Abra la vista de Correo para ver todos los atajos.",
|
"Open the Mail view to see all shortcuts.": "Abra la vista de Correo para ver todos los atajos.",
|
||||||
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Los atajos al estilo de Gmail están siempre activos. Pulse {key} en cualquier momento para ver esta lista.",
|
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Los atajos al estilo de Gmail están siempre activos. Pulse {key} en cualquier momento para ver esta lista.",
|
||||||
"Select a conversation to read it here · Press {key} for shortcuts": "Seleccione una conversación para leerla aquí · {key} para los atajos",
|
"Select a conversation to read it here · Press {key} for shortcuts": "Seleccione una conversación para leerla aquí · {key} para los atajos",
|
||||||
|
"Select a message to read it here · Press {key} for shortcuts": "Seleccione un mensaje para leerlo aquí · {key} para los atajos",
|
||||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Consejo: pulse {key} sobre una conversación para aplicar etiquetas. Busque con {operator}.",
|
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Consejo: pulse {key} sobre una conversación para aplicar etiquetas. Busque con {operator}.",
|
||||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Un webmail libre, rápido y agradable para {server}, construido sobre JMAP.",
|
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Un webmail libre, rápido y agradable para {server}, construido sobre JMAP.",
|
||||||
"Defaults for the calendar views and new events.": "Valores predeterminados de las vistas del calendario y de los eventos nuevos.",
|
"Defaults for the calendar views and new events.": "Valores predeterminados de las vistas del calendario y de los eventos nuevos.",
|
||||||
@@ -856,11 +993,12 @@ export const catalog: Catalog = {
|
|||||||
"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 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.",
|
||||||
"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}.",
|
||||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} es la paleta de {site}, y con la que empieza una cuenta nueva. Es un tema oscuro, así que cuenta como oscuro allí donde importa, y el color de acento de abajo se sigue aplicando encima.",
|
|
||||||
"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.",
|
"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.",
|
||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Mensaje nuevo",
|
"New message": "Mensaje 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",
|
||||||
"Sending…": "Enviando…",
|
"Sending…": "Enviando…",
|
||||||
"Saving…": "Guardando…",
|
"Saving…": "Guardando…",
|
||||||
"Error": "Error",
|
"Error": "Error",
|
||||||
@@ -901,6 +1039,7 @@ export const catalog: Catalog = {
|
|||||||
"Could not copy the address": "No se pudo copiar la dirección",
|
"Could not copy the address": "No se pudo copiar la dirección",
|
||||||
"Could not empty folder: {error}": "No se pudo vaciar la carpeta: {error}",
|
"Could not empty folder: {error}": "No se pudo vaciar la carpeta: {error}",
|
||||||
"Could not load source: {error}": "No se pudo cargar el código fuente: {error}",
|
"Could not load source: {error}": "No se pudo cargar el código fuente: {error}",
|
||||||
|
"Could not share: {error}": "No se pudo compartir: {error}",
|
||||||
"Could not mark as read: {error}": "No se pudo marcar como leído: {error}",
|
"Could not mark as read: {error}": "No se pudo marcar como leído: {error}",
|
||||||
"Could not save draft: {error}": "No se pudo guardar el borrador: {error}",
|
"Could not save draft: {error}": "No se pudo guardar el borrador: {error}",
|
||||||
"Could not save filter: {error}": "No se pudo guardar el filtro: {error}",
|
"Could not save filter: {error}": "No se pudo guardar el filtro: {error}",
|
||||||
@@ -1109,7 +1248,7 @@ export const catalog: Catalog = {
|
|||||||
"Date received": "Fecha de recepción",
|
"Date received": "Fecha de recepción",
|
||||||
"Date sent": "Fecha de envío",
|
"Date sent": "Fecha de envío",
|
||||||
"Day view": "Vista de día",
|
"Day view": "Vista de día",
|
||||||
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula, Gruvbox, Rosé Pine y Tokyo Night son obra de sus propios proyectos y se usan bajo la licencia MIT; los tonos intermedios entre sus colores publicados son derivados, y todos se comprueban en cuanto a contraste. El color de acento de abajo sigue aplicándose sobre cualquiera de ellos.",
|
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "Las paletas que llevan el nombre de otro proyecto son obra de ese proyecto y se usan bajo su propia licencia; los tonos intermedios entre sus colores publicados son derivados, y cada uno se comprueba para el contraste. El color de acento de abajo sigue aplicándose sobre cualquiera de ellas.",
|
||||||
"Earlier": "Antes",
|
"Earlier": "Antes",
|
||||||
"Every folder": "Todas las carpetas",
|
"Every folder": "Todas las carpetas",
|
||||||
"Everyone addressed will receive this.": "Todos los destinatarios lo recibirán.",
|
"Everyone addressed will receive this.": "Todos los destinatarios lo recibirán.",
|
||||||
@@ -1210,6 +1349,14 @@ export const catalog: Catalog = {
|
|||||||
"Throw away your changes?": "¿Descartar los cambios?",
|
"Throw away your changes?": "¿Descartar los cambios?",
|
||||||
"Today, in your date format": "Hoy, en su formato de fecha",
|
"Today, in your date format": "Hoy, en su formato de fecha",
|
||||||
"Unread first": "Los no leídos primero",
|
"Unread first": "Los no leídos primero",
|
||||||
|
"Read first": "Los leídos primero",
|
||||||
|
"Unstarred first": "Los no destacados primero",
|
||||||
|
"Smallest first": "Los más pequeños primero",
|
||||||
|
"Z to A": "De la Z a la A",
|
||||||
|
"A to Z": "De la A a la Z",
|
||||||
|
"It reads {shown} but goes to {actual}.": "Dice {shown}, pero lleva a {actual}.",
|
||||||
|
"The full address is {href}.": "La dirección completa es {href}.",
|
||||||
|
"This message came from {domain}, which is outside your organisation.": "Este mensaje procede de {domain}, que está fuera de su organización.",
|
||||||
"Unsaved changes": "Cambios sin guardar",
|
"Unsaved changes": "Cambios sin guardar",
|
||||||
"View as": "Ver como",
|
"View as": "Ver como",
|
||||||
"Warnings": "Avisos",
|
"Warnings": "Avisos",
|
||||||
@@ -1255,8 +1402,119 @@ export const catalog: Catalog = {
|
|||||||
"This message was sent automatically, so no read receipt is offered.": "Este mensaje se envió automáticamente, así que no se ofrece confirmación de lectura.",
|
"This message was sent automatically, so no read receipt is offered.": "Este mensaje se envió automáticamente, así que no se ofrece confirmación de lectura.",
|
||||||
"This server will not hold a message longer than {span}.": "Este servidor no retiene un mensaje más de {span}.",
|
"This server will not hold a message longer than {span}.": "Este servidor no retiene un mensaje más de {span}.",
|
||||||
"Upload failed": "Error al subir",
|
"Upload failed": "Error al subir",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
"Every {n} {frequency}": "Cada {n} {frequency}",
|
||||||
|
"Monthly": "Mensualmente",
|
||||||
|
"Monthly on day {days}": "Mensualmente el día {days}",
|
||||||
|
"Monthly on the {ordinal} {weekday}": "Mensualmente el {ordinal} {weekday}",
|
||||||
|
"Monthly on {weekday}": "Mensualmente el {weekday}",
|
||||||
|
"Weekly": "Semanalmente",
|
||||||
|
"Weekly on {days}": "Semanalmente los {days}",
|
||||||
|
"add {flag}": "añadir {flag}",
|
||||||
|
"always": "siempre",
|
||||||
|
"body contains \"{value}\"": "el cuerpo contiene \"{value}\"",
|
||||||
|
"body does not contain \"{value}\"": "el cuerpo no contiene \"{value}\"",
|
||||||
|
"delete it": "eliminarlo",
|
||||||
|
"fifth": "quinto",
|
||||||
|
"first": "primer",
|
||||||
|
"forward to {address}": "reenviar a {address}",
|
||||||
|
"fourth": "cuarto",
|
||||||
|
"keep it": "conservarlo",
|
||||||
|
"last": "último",
|
||||||
|
"mark it read": "marcarlo como leído",
|
||||||
|
"move to {folder}": "mover a {folder}",
|
||||||
|
"reject it": "rechazarlo",
|
||||||
|
"remove {flag}": "quitar {flag}",
|
||||||
|
"second": "segundo",
|
||||||
|
"size is over {n} KB": "el tamaño supera {n} KB",
|
||||||
|
"size is under {n} KB": "el tamaño es inferior a {n} KB",
|
||||||
|
"star it": "destacarlo",
|
||||||
|
"stop": "detener",
|
||||||
|
"third": "tercer",
|
||||||
|
"{header} address {op} \"{value}\"": "la dirección de {header} {op} \"{value}\"",
|
||||||
|
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
|
||||||
|
"{rule}, until {date}": "{rule}, hasta el {date}",
|
||||||
|
"{tests} → {actions}": "{tests} → {actions}",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"This cannot be undone.": "Esto no se puede deshacer.",
|
||||||
|
"Some could not be deleted: {error}": "Algunos no se pudieron eliminar: {error}",
|
||||||
|
"It was not deleted": "No se ha eliminado",
|
||||||
|
"Empty address book": "Vaciar esta libreta de direcciones",
|
||||||
|
"There is nothing in it to delete": "No hay nada dentro que eliminar",
|
||||||
|
"Empty “{name}”?": "¿Vaciar «{name}»?",
|
||||||
|
"Delete them": "Eliminarlos",
|
||||||
|
"Nothing was deleted": "No se ha eliminado nada",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "El certificado cubre",
|
||||||
|
"Details": "Detalles",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Los mensajes anteriores de esta dirección los firmaba {previous}. Este lo firma {current}.",
|
||||||
|
"Fingerprint": "Huella digital",
|
||||||
|
"Hide details": "Ocultar detalles",
|
||||||
|
"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 uses a signature algorithm ihasmail cannot check yet.": "Usa un algoritmo de firma que ihasmail 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.",
|
||||||
|
"Previous fingerprint": "Huella digital anterior",
|
||||||
|
"Signed at": "Firmado el",
|
||||||
|
"Signed by {name} — the same signer as before.": "Firmado por {name}: el mismo firmante que antes.",
|
||||||
|
"Signed by {name}, seen here for the first time.": "Firmado por {name}, visto aquí por primera vez.",
|
||||||
|
"Signer": "Firmante",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Puede tratarse de un certificado renovado, y puede tratarse de otra persona. Compruébelo con ella por otra vía antes de confiar.",
|
||||||
|
"The certificate has expired.": "El certificado ha caducado.",
|
||||||
|
"The certificate is not valid yet.": "El certificado aún no es válido.",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "El mensaje no coincide con lo que se firmó: se alteró después de firmarlo, o se dañó por el camino.",
|
||||||
|
"The signature carries no certificate that can be read.": "La firma no lleva ningún certificado que se pueda leer.",
|
||||||
|
"The signature could not be read.": "No se ha podido leer la firma.",
|
||||||
|
"The signature does not match the certificate sent with it.": "La firma no coincide con el certificado enviado con ella.",
|
||||||
|
"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 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 signature does not check out.": "Esta firma no cuadra.",
|
||||||
|
"Valid until": "Válido hasta",
|
||||||
|
"a different certificate": "un certificado distinto",
|
||||||
|
"an unnamed signer": "un firmante sin nombre",
|
||||||
|
"as claimed by the signer": "según declara el firmante",
|
||||||
|
"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.",
|
||||||
|
"itself, or an issuer it does not name": "sí mismo, o un emisor que no nombra",
|
||||||
|
"no address": "ninguna dirección",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── 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." },
|
||||||
|
"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." },
|
||||||
|
"{n} domains": { one: "{n} dominio", other: "{n} dominios" },
|
||||||
|
"{n} mailing lists": { one: "{n} lista de correo", other: "{n} listas de correo" },
|
||||||
|
"{n} DKIM keys": { one: "{n} clave DKIM", other: "{n} claves DKIM" },
|
||||||
|
"{n} other items": { one: "{n} elemento más", other: "{n} elementos más" },
|
||||||
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
"{n} accounts": { one: "{n} cuenta", other: "{n} cuentas" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Delete {n} items": { one: "Eliminar {n} elemento", other: "Eliminar {n} elementos" },
|
||||||
|
"Delete {n} items?": { one: "¿Eliminar {n} elemento?", other: "¿Eliminar {n} elementos?" },
|
||||||
|
"Move {n} items": { one: "Mover {n} elemento", other: "Mover {n} elementos" },
|
||||||
|
"Move {n} items…": { one: "Mover {n} elemento…", other: "Mover {n} elementos…" },
|
||||||
|
"The event runs {n} days longer than this shows.": { one: "El evento dura {n} día más de lo que se muestra aquí.", other: "El evento dura {n} días más de lo que se muestra aquí." },
|
||||||
|
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} invitado no está en este servidor, así que no hay información de libre/ocupado para él.", other: "{n} invitados no están en este servidor, así que no hay información de libre/ocupado para ellos." },
|
||||||
|
"{n} items selected": { one: "{n} elemento seleccionado", other: "{n} elementos seleccionados" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Every {n} days": { one: "Cada día", other: "Cada {n} días" },
|
||||||
|
"Every {n} months": { one: "Cada mes", other: "Cada {n} meses" },
|
||||||
|
"Every {n} months on day {days}": { one: "Cada mes el día {days}", other: "Cada {n} meses el día {days}" },
|
||||||
|
"Every {n} months on the {ordinal} {weekday}": { one: "Cada mes el {ordinal} {weekday}", other: "Cada {n} meses el {ordinal} {weekday}" },
|
||||||
|
"Every {n} months on {weekday}": { one: "Cada mes el {weekday}", other: "Cada {n} meses el {weekday}" },
|
||||||
|
"Every {n} weeks": { one: "Cada semana", other: "Cada {n} semanas" },
|
||||||
|
"Every {n} weeks on {days}": { one: "Cada semana los {days}", other: "Cada {n} semanas los {days}" },
|
||||||
|
"Every {n} years": { one: "Cada año", other: "Cada {n} años" },
|
||||||
|
"{rule}, {n} times": { one: "{rule}, {n} vez", other: "{rule}, {n} veces" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
"Move {n} messages to Trash?": { one: "¿Mover {n} mensaje a la Papelera?", other: "¿Mover {n} mensajes a la Papelera?" },
|
"Move {n} messages to Trash?": { one: "¿Mover {n} mensaje a la Papelera?", other: "¿Mover {n} mensajes a la Papelera?" },
|
||||||
"{n} days": { one: "{n} día", other: "{n} días" },
|
"{n} days": { one: "{n} día", other: "{n} días" },
|
||||||
@@ -1268,8 +1526,7 @@ export const catalog: Catalog = {
|
|||||||
"Your administrator changed {n} settings": { one: "Tu administración cambió {n} ajuste", other: "Tu administración cambió {n} ajustes" },
|
"Your administrator changed {n} settings": { one: "Tu administración cambió {n} ajuste", other: "Tu administración cambió {n} ajustes" },
|
||||||
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
|
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
|
||||||
"Imported {n} events": { one: "{n} evento importado", other: "{n} eventos importados" },
|
"Imported {n} events": { one: "{n} evento importado", other: "{n} eventos importados" },
|
||||||
"Already here: {n} events, nothing imported": { one: "Ya estaba aquí: {n} evento, no se importó nada", other: "Ya estaban aquí: {n} eventos, no se importó nada" },
|
"Updated {n} events, nothing new": { one: "{n} evento actualizado, nada nuevo", other: "{n} eventos actualizados, nada nuevo" },
|
||||||
"{n} were already here": { one: "{n} ya estaba aquí", other: "{n} ya estaban aquí" },
|
|
||||||
"{n} messages": { one: "{n} mensaje", other: "{n} mensajes" },
|
"{n} messages": { one: "{n} mensaje", other: "{n} mensajes" },
|
||||||
"{n} selected": { one: "{n} seleccionado", other: "{n} seleccionados" },
|
"{n} selected": { one: "{n} seleccionado", other: "{n} seleccionados" },
|
||||||
"{n} conversations": { one: "{n} conversación", other: "{n} conversaciones" },
|
"{n} conversations": { one: "{n} conversación", other: "{n} conversaciones" },
|
||||||
@@ -1288,5 +1545,10 @@ export const catalog: Catalog = {
|
|||||||
"Marked {n} messages as read": { one: "{n} mensaje marcado como leído", other: "{n} mensajes marcados como leídos" },
|
"Marked {n} messages as read": { one: "{n} mensaje marcado como leído", other: "{n} mensajes marcados como leídos" },
|
||||||
"in {n} folders": { one: "en {n} carpeta", other: "en {n} carpetas" },
|
"in {n} folders": { one: "en {n} carpeta", other: "en {n} carpetas" },
|
||||||
"Deleted {n} messages": { one: "{n} mensaje eliminado", other: "{n} mensajes eliminados" },
|
"Deleted {n} messages": { one: "{n} mensaje eliminado", other: "{n} mensajes eliminados" },
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"Delete {n} contacts?": { one: "¿Eliminar {n} contacto?", other: "¿Eliminar {n} contactos?" },
|
||||||
|
"Deleted {n} contacts": { one: "Se ha eliminado {n} contacto", other: "Se han eliminado {n} contactos" },
|
||||||
|
"{n} contacts will be deleted. This cannot be undone.": { one: "Se eliminará {n} contacto. Esto no se puede deshacer.", other: "Se eliminarán {n} contactos. Esto no se puede deshacer." },
|
||||||
|
"{n} were also in other address books and were only removed from this one": { one: "{n} contacto también estaba en otra libreta de direcciones y solo se ha quitado de esta", other: "{n} contactos también estaban en otras libretas de direcciones y solo se han quitado de esta" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+275
-13
@@ -52,6 +52,146 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
strings: {
|
||||||
|
// ── Administration: domains ────────────────────────────────────
|
||||||
|
"Domains": "Domaines",
|
||||||
|
"By hand": "Manuel",
|
||||||
|
"Signing": "Signe",
|
||||||
|
"Published, not signing yet": "Publiée, ne signe pas encore",
|
||||||
|
"Retiring": "En retrait",
|
||||||
|
"Retired": "Retirée",
|
||||||
|
"This domain no longer exists. Someone may have removed it.": "Ce domaine n’existe plus. Quelqu’un l’a peut-être retiré.",
|
||||||
|
"That doesn't look like a domain name, such as example.com.": "Cela ne ressemble pas à un nom de domaine, comme example.com.",
|
||||||
|
"Added {name}. Its DNS records are ready to copy.": "{name} ajouté. Ses enregistrements DNS sont prêts à être copiés.",
|
||||||
|
"Saved {name}": "{name} enregistré",
|
||||||
|
"Add domain": "Ajouter un domaine",
|
||||||
|
"Added {date}": "Ajouté le {date}",
|
||||||
|
"This domain is disabled on the server.": "Ce domaine est désactivé sur le serveur.",
|
||||||
|
"Your role lets you view domains but not change them.": "Votre rôle vous permet de consulter les domaines, mais pas de les modifier.",
|
||||||
|
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Les nouveaux domaines signent leurs messages avec des clés DKIM que le serveur crée et renouvelle. Leurs enregistrements DNS apparaissent ici une fois le domaine ajouté.",
|
||||||
|
"Other names": "Autres noms",
|
||||||
|
"Delivery": "Distribution",
|
||||||
|
"Catch-all address": "Adresse fourre-tout",
|
||||||
|
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "Les messages envoyés à une adresse que personne n’a sur ce domaine sont remis ici. Laissez vide pour les refuser.",
|
||||||
|
"Plus addressing": "Adresses avec +",
|
||||||
|
"Set by a custom rule on the server.": "Défini par une règle personnalisée sur le serveur.",
|
||||||
|
"Mail to name+anything@ is delivered to name@.": "Les messages à nom+nimportequoi@ sont remis à nom@.",
|
||||||
|
"DNS records": "Enregistrements DNS",
|
||||||
|
"Published automatically through {provider}.": "Publiés automatiquement via {provider}.",
|
||||||
|
"Published automatically by the server.": "Publiés automatiquement par le serveur.",
|
||||||
|
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Ajoutez-les là où le DNS de ce domaine est hébergé. Les messages ne sont ni distribués ni jugés fiables tant qu’ils ne sont pas en place.",
|
||||||
|
"Copy {type} record for {name}": "Copier l’enregistrement {type} pour {name}",
|
||||||
|
"Copy value": "Copier la valeur",
|
||||||
|
"Copied the zone file": "Fichier de zone copié",
|
||||||
|
"Copy all as a zone file": "Tout copier en fichier de zone",
|
||||||
|
"The server returned no records for this domain.": "Le serveur n’a renvoyé aucun enregistrement pour ce domaine.",
|
||||||
|
"DKIM keys": "Clés DKIM",
|
||||||
|
"The server creates and rotates these keys itself.": "Le serveur crée et renouvelle ces clés lui-même.",
|
||||||
|
"These keys are managed by hand on the server.": "Ces clés sont gérées manuellement sur le serveur.",
|
||||||
|
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "Aucune clé DKIM : les messages de ce domaine ne sont pas signés et risquent davantage d’être classés comme spam.",
|
||||||
|
"Managed by the server": "Géré par le serveur",
|
||||||
|
"Certificate": "Certificat",
|
||||||
|
"Another name for this domain": "Autre nom pour ce domaine",
|
||||||
|
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "Les messages envoyés à la même adresse sous l’un de ces noms arrivent dans le même compte. Les modifications s’appliquent à l’enregistrement.",
|
||||||
|
"Its DKIM keys have to be removed first, and your role can't remove them.": "Ses clés DKIM doivent d’abord être supprimées, et votre rôle ne le permet pas.",
|
||||||
|
"The server stops accepting mail for this domain.": "Le serveur n’accepte plus de messages pour ce domaine.",
|
||||||
|
"Remove domain…": "Retirer le domaine…",
|
||||||
|
"Remove {name}?": "Retirer {name} ?",
|
||||||
|
"Removed {name}": "{name} retiré",
|
||||||
|
"The server kept the domain: it is still used by {things}.": "Le serveur a conservé le domaine : il est encore utilisé par {things}.",
|
||||||
|
"Remove domain": "Retirer le domaine",
|
||||||
|
"The server stops accepting mail for this domain. This can't be undone.": "Le serveur n’accepte plus de messages pour ce domaine. C’est irréversible.",
|
||||||
|
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Là où vivent vos adresses, et les enregistrements DNS qui permettent aux messages d’arriver et d’être fiables.",
|
||||||
|
"Search domains": "Rechercher des domaines",
|
||||||
|
"No domains match": "Aucun domaine correspondant",
|
||||||
|
"No domains yet": "Aucun domaine pour l’instant",
|
||||||
|
"DKIM": "DKIM",
|
||||||
|
"Tenant": "Locataire",
|
||||||
|
"Disabled": "Désactivé",
|
||||||
|
"also {names}": "aussi {names}",
|
||||||
|
"The server did not say whether the domain was created.": "Le serveur n’a pas indiqué si le domaine a été créé.",
|
||||||
|
// ── Administration: refusals ───────────────────────────────────
|
||||||
|
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Ce n’est pas un nom de domaine valide. Utilisez un nom comme example.com, avec un vrai domaine de premier niveau.",
|
||||||
|
"That isn't a valid email address. Use a full address, such as [email protected].": "Ce n’est pas une adresse e-mail valide. Utilisez une adresse complète, comme [email protected].",
|
||||||
|
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Ce n’est pas une adresse valide. Utilisez des lettres, des chiffres, des points, des tirets ou des traits de soulignement avant le @.",
|
||||||
|
"That isn't a valid host name or IP address.": "Ce n’est ni un nom d’hôte ni une adresse IP valide.",
|
||||||
|
"A required value was left empty.": "Une valeur obligatoire a été laissée vide.",
|
||||||
|
"Administration is turned off on this installation.": "L’administration est désactivée sur cette installation.",
|
||||||
|
"The mail server could not carry out the request ({code}).": "Le serveur de messagerie n’a pas pu traiter la demande ({code}).",
|
||||||
|
"You can't give an account permissions your own role doesn't have.": "Vous ne pouvez pas donner à un compte des autorisations que votre propre rôle n’a pas.",
|
||||||
|
"This account signs in through an external directory, so its password can't be set here.": "Ce compte se connecte via un annuaire externe, son mot de passe ne peut donc pas être défini ici.",
|
||||||
|
"The server's licence allows no more accounts.": "La licence du serveur ne permet pas de comptes supplémentaires.",
|
||||||
|
"That domain name is already in use on this server, as a domain or another domain's other name.": "Ce nom de domaine est déjà utilisé sur ce serveur, comme domaine ou comme autre nom d’un autre domaine.",
|
||||||
|
"Your organisation has reached the number of domains it is allowed.": "Votre organisation a atteint le nombre de domaines autorisé.",
|
||||||
|
"That is more than the mail server accepts in one change.": "C’est plus que ce que le serveur de messagerie accepte en une seule modification.",
|
||||||
|
"The mail server rejected one of the values. Check what you entered and try again.": "Le serveur de messagerie a refusé l’une des valeurs. Vérifiez votre saisie et réessayez.",
|
||||||
|
"The mail server refused the change ({code}).": "Le serveur de messagerie a refusé la modification ({code}).",
|
||||||
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
|
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Uniquement sur un appareil que vous avez indiqué comme le vôtre. Reconnectez-vous en cochant « Cet appareil est le mien ».",
|
||||||
|
"Change your own password in {settings}.": "Modifiez votre propre mot de passe dans {settings}.",
|
||||||
|
"Administration": "Administration",
|
||||||
|
"Directory": "Annuaire",
|
||||||
|
"User": "Utilisateur",
|
||||||
|
"Administrator": "Administrateur",
|
||||||
|
"Custom role": "Rôle personnalisé",
|
||||||
|
"New account": "Nouveau compte",
|
||||||
|
"The people who sign in to mail on the domains you manage.": "Les personnes qui se connectent à leur messagerie sur les domaines que vous gérez.",
|
||||||
|
"Search by name or address": "Rechercher par nom ou adresse",
|
||||||
|
"Search accounts": "Rechercher des comptes",
|
||||||
|
"No accounts match": "Aucun compte correspondant",
|
||||||
|
"No accounts yet": "Aucun compte pour l’instant",
|
||||||
|
"Nothing on your domains matches “{query}”.": "Rien ne correspond à « {query} » sur vos domaines.",
|
||||||
|
"Open {address}": "Ouvrir {address}",
|
||||||
|
"{from}–{to} of {total}": "{from}–{to} sur {total}",
|
||||||
|
"Previous page": "Page précédente",
|
||||||
|
"Next page": "Page suivante",
|
||||||
|
"Storage": "Stockage",
|
||||||
|
"Groups": "Groupes",
|
||||||
|
"{used} · no limit": "{used} · sans limite",
|
||||||
|
"Profile": "Profil",
|
||||||
|
"Domain": "Domaine",
|
||||||
|
"No domains are available to create an account on.": "Aucun domaine n’est disponible pour créer un compte.",
|
||||||
|
"Sign-in": "Connexion",
|
||||||
|
"Other addresses": "Autres adresses",
|
||||||
|
"Not in any group": "Membre d’aucun groupe",
|
||||||
|
"You can't change your own role.": "Vous ne pouvez pas modifier votre propre rôle.",
|
||||||
|
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Seuls les rôles dont vous détenez vous-même les autorisations sont proposés. Pour un compte au sein d’un locataire, Administrateur signifie administrateur de ce locataire.",
|
||||||
|
"Limit in GB": "Limite en Go",
|
||||||
|
"No limit": "Sans limite",
|
||||||
|
"This account has permissions yours doesn't, so you can view it but not change it.": "Ce compte a des autorisations que le vôtre n’a pas : vous pouvez le consulter, mais pas le modifier.",
|
||||||
|
"Your role lets you view accounts but not change them.": "Votre rôle vous permet de consulter les comptes, mais pas de les modifier.",
|
||||||
|
"This account has permissions yours doesn't.": "Ce compte a des autorisations que le vôtre n’a pas.",
|
||||||
|
"You can't delete the account you're signed in with.": "Vous ne pouvez pas supprimer le compte avec lequel vous êtes connecté.",
|
||||||
|
"Create account": "Créer le compte",
|
||||||
|
"An account needs an address.": "Un compte doit avoir une adresse.",
|
||||||
|
"Created {address}": "{address} créé",
|
||||||
|
"Saved {address}": "{address} enregistré",
|
||||||
|
"Generate a password": "Générer un mot de passe",
|
||||||
|
"Pass it on some way other than email to this address.": "Transmettez-le autrement que par e-mail à cette adresse.",
|
||||||
|
"This account has no password. It may sign in through a directory or single sign-on.": "Ce compte n’a pas de mot de passe. Il se connecte peut-être via un annuaire ou une authentification unique.",
|
||||||
|
"Set a new password…": "Définir un nouveau mot de passe…",
|
||||||
|
"{name} will be signed out of every app and device using the old password.": "{name} sera déconnecté de toutes les applications et de tous les appareils qui utilisent l’ancien mot de passe.",
|
||||||
|
"New password set for {address}": "Nouveau mot de passe défini pour {address}",
|
||||||
|
"Set password": "Définir le mot de passe",
|
||||||
|
"Remove {address}": "Retirer {address}",
|
||||||
|
"New address": "Nouvelle adresse",
|
||||||
|
"another name": "autre nom",
|
||||||
|
"Mail to these addresses is delivered to this account. Changes apply when you save.": "Les messages envoyés à ces adresses sont remis à ce compte. Les modifications s’appliquent à l’enregistrement.",
|
||||||
|
"Deletes the mailbox and everything in it.": "Supprime la boîte aux lettres et tout son contenu.",
|
||||||
|
"Delete account…": "Supprimer le compte…",
|
||||||
|
"Delete {address}?": "Supprimer {address} ?",
|
||||||
|
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Cette action supprime les messages, agendas, contacts et fichiers de ce compte. Le serveur les efface en arrière-plan, et c’est irréversible.",
|
||||||
|
"Type {address} to confirm": "Saisissez {address} pour confirmer",
|
||||||
|
"Delete account": "Supprimer le compte",
|
||||||
|
"Deleted {address}": "{address} supprimé",
|
||||||
|
"The server did not say whether the account was created.": "Le serveur n’a pas indiqué si le compte a été créé.",
|
||||||
|
"The mail server refused this. Your role may not allow it.": "Le serveur de messagerie a refusé. Votre rôle ne le permet peut-être pas.",
|
||||||
|
"That address is already in use on this server, as an account, a list or an alias.": "Cette adresse est déjà utilisée sur ce serveur, par un compte, une liste ou un alias.",
|
||||||
|
"One of the chosen domain, role or group can't be used for this account.": "Le domaine, le rôle ou le groupe choisi ne peut pas être utilisé pour ce compte.",
|
||||||
|
"Your organisation has reached the number of accounts it is allowed.": "Votre organisation a atteint le nombre de comptes autorisé.",
|
||||||
|
"Something still depends on this, so the server kept it.": "Un autre élément en dépend encore, le serveur l’a donc conservé.",
|
||||||
|
"This account no longer exists. Someone may have deleted it.": "Ce compte n’existe plus. Quelqu’un l’a peut-être supprimé.",
|
||||||
|
"The password was not accepted: {reason}": "Le mot de passe a été refusé : {reason}",
|
||||||
|
"The password was not accepted.": "Le mot de passe a été refusé.",
|
||||||
"Go to folder…": "Aller au dossier…",
|
"Go to folder…": "Aller au dossier…",
|
||||||
"Set for everyone here. You cannot change this.": "Défini pour tout le monde ici. Vous ne pouvez pas le modifier.",
|
"Set for everyone here. You cannot change this.": "Défini pour tout le monde ici. Vous ne pouvez pas le modifier.",
|
||||||
"Export iCAL file": "Exporter un fichier iCAL",
|
"Export iCAL file": "Exporter un fichier iCAL",
|
||||||
@@ -324,7 +464,6 @@ export const catalog: Catalog = {
|
|||||||
"Busy": "Occupé",
|
"Busy": "Occupé",
|
||||||
"Free/busy": "Disponibilité",
|
"Free/busy": "Disponibilité",
|
||||||
"Show as": "Afficher comme",
|
"Show as": "Afficher comme",
|
||||||
"Availability on {date}": "Disponibilité le {date}",
|
|
||||||
"Count all events as busy": "Compter tous les événements comme occupé",
|
"Count all events as busy": "Compter tous les événements comme occupé",
|
||||||
"Only events I'm attending": "Uniquement les événements auxquels je participe",
|
"Only events I'm attending": "Uniquement les événements auxquels je participe",
|
||||||
"Don't include in availability": "Ne pas inclure dans la disponibilité",
|
"Don't include in availability": "Ne pas inclure dans la disponibilité",
|
||||||
@@ -363,7 +502,6 @@ export const catalog: Catalog = {
|
|||||||
"New address book": "Nouveau carnet d'adresses",
|
"New address book": "Nouveau carnet d'adresses",
|
||||||
"No address books yet.": "Aucun carnet d'adresses pour le moment.",
|
"No address books yet.": "Aucun carnet d'adresses pour le moment.",
|
||||||
"Choose from address books": "Choisir dans les carnets d'adresses",
|
"Choose from address books": "Choisir dans les carnets d'adresses",
|
||||||
"Import vCard": "Importer une vCard",
|
|
||||||
"Export all contacts": "Exporter tous les contacts",
|
"Export all contacts": "Exporter tous les contacts",
|
||||||
"Export address book": "Exporter ce carnet d’adresses",
|
"Export address book": "Exporter ce carnet d’adresses",
|
||||||
"Import contacts…": "Importer des contacts…",
|
"Import contacts…": "Importer des contacts…",
|
||||||
@@ -438,7 +576,6 @@ export const catalog: Catalog = {
|
|||||||
"Make ihasmail yours.": "Faites de ihasmail le vôtre.",
|
"Make ihasmail yours.": "Faites de ihasmail le vôtre.",
|
||||||
"Reading": "Lecture",
|
"Reading": "Lecture",
|
||||||
"Reading pane": "Volet de lecture",
|
"Reading pane": "Volet de lecture",
|
||||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "Comportement de lecture, d'envoi et de liste. Les paramètres sont enregistrés dans ce navigateur.",
|
|
||||||
"Right of the list": "À droite de la liste",
|
"Right of the list": "À droite de la liste",
|
||||||
"Below the list": "Sous la liste",
|
"Below the list": "Sous la liste",
|
||||||
"Hidden (open full width)": "Masqué (ouvrir en pleine largeur)",
|
"Hidden (open full width)": "Masqué (ouvrir en pleine largeur)",
|
||||||
@@ -481,10 +618,6 @@ export const catalog: Catalog = {
|
|||||||
"Time zone": "Fuseau horaire",
|
"Time zone": "Fuseau horaire",
|
||||||
"Week starts on": "La semaine commence le",
|
"Week starts on": "La semaine commence le",
|
||||||
"Monday": "Lundi",
|
"Monday": "Lundi",
|
||||||
"Tuesday": "Mardi",
|
|
||||||
"Wednesday": "Mercredi",
|
|
||||||
"Thursday": "Jeudi",
|
|
||||||
"Friday": "Vendredi",
|
|
||||||
"Saturday": "Samedi",
|
"Saturday": "Samedi",
|
||||||
"Sunday": "Dimanche",
|
"Sunday": "Dimanche",
|
||||||
"12-hour clock (6:23 PM)": "Format 12 heures (6:23 PM)",
|
"12-hour clock (6:23 PM)": "Format 12 heures (6:23 PM)",
|
||||||
@@ -524,6 +657,8 @@ export const catalog: Catalog = {
|
|||||||
"Show labels in the sidebar": "Afficher les libellés dans la barre latérale",
|
"Show labels in the sidebar": "Afficher les libellés dans la barre latérale",
|
||||||
"Collapse sidebar to icons": "Réduire la barre latérale en icônes",
|
"Collapse sidebar to icons": "Réduire la barre latérale en icônes",
|
||||||
"Apply the theme to messages too": "Appliquer le thème aux messages",
|
"Apply the theme to messages too": "Appliquer le thème aux messages",
|
||||||
|
"Apply it even to mail that styles itself": "L'appliquer même aux messages qui se mettent en forme",
|
||||||
|
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Presque tous les courriers publicitaires et les reçus définissent une couleur quelque part, si bien que le réglage ci-dessus en laisse la quasi-totalité sur une carte blanche. Avec cette option, le thème est imposé par-dessus les couleurs de l'expéditeur : les fonds sur lesquels le message repose sont supprimés, tandis que les boutons et les bandeaux colorés sont conservés pour que leur texte reste lisible. Certains messages n'y survivront pas intacts, d'où un réglage distinct.",
|
||||||
"Swiping": "Balayage",
|
"Swiping": "Balayage",
|
||||||
"Swipe left": "Balayer vers la gauche",
|
"Swipe left": "Balayer vers la gauche",
|
||||||
"Swipe right": "Balayer vers la droite",
|
"Swipe right": "Balayer vers la droite",
|
||||||
@@ -728,10 +863,8 @@ export const catalog: Catalog = {
|
|||||||
"Manage labels": "Gérer les libellés",
|
"Manage labels": "Gérer les libellés",
|
||||||
"Create “{name}”": "Créer « {name} »",
|
"Create “{name}”": "Créer « {name} »",
|
||||||
"Type a name to create your first label.": "Saisissez un nom pour créer votre premier libellé.",
|
"Type a name to create your first label.": "Saisissez un nom pour créer votre premier libellé.",
|
||||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Les libellés sont des mots-clés IMAP enregistrés dans vos messages : ils se synchronisent donc avec les autres clients. Les noms et couleurs restent dans ce navigateur.",
|
|
||||||
"New label": "Nouveau libellé",
|
"New label": "Nouveau libellé",
|
||||||
"Delete label": "Supprimer le libellé",
|
"Delete label": "Supprimer le libellé",
|
||||||
"PDF": "PDF",
|
|
||||||
"Large attachments may be rejected by some servers": "Les pièces jointes volumineuses peuvent être refusées par certains serveurs",
|
"Large attachments may be rejected by some servers": "Les pièces jointes volumineuses peuvent être refusées par certains serveurs",
|
||||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Les images sont enregistrées dans vos Fichiers (dossier « ihasmail ») et intégrées à l'envoi.",
|
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Les images sont enregistrées dans vos Fichiers (dossier « ihasmail ») et intégrées à l'envoi.",
|
||||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Merci pour votre message. Je suis absent jusqu'au … et vous répondrai à mon retour.",
|
"Thanks for your message. I'm away until … and will reply when I'm back.": "Merci pour votre message. Je suis absent jusqu'au … et vous répondrai à mon retour.",
|
||||||
@@ -804,6 +937,8 @@ export const catalog: Catalog = {
|
|||||||
"folder\u0004Junk Mail": "Spam",
|
"folder\u0004Junk Mail": "Spam",
|
||||||
"folder\u0004Important": "Important",
|
"folder\u0004Important": "Important",
|
||||||
"folder\u0004All mail": "Tous les messages",
|
"folder\u0004All mail": "Tous les messages",
|
||||||
|
"share sheet\u0004Share": "Partager",
|
||||||
|
"share sheet\u0004Share…": "Partager…",
|
||||||
"folder": "dossier",
|
"folder": "dossier",
|
||||||
"“{name}” moved into “{parent}”": "« {name} » a été déplacé dans « {parent} »",
|
"“{name}” moved into “{parent}”": "« {name} » a été déplacé dans « {parent} »",
|
||||||
"“{name}” moved to the top level": "« {name} » a été déplacé au niveau supérieur",
|
"“{name}” moved to the top level": "« {name} » a été déplacé au niveau supérieur",
|
||||||
@@ -812,6 +947,7 @@ export const catalog: Catalog = {
|
|||||||
"Rename folder": "Renommer le dossier",
|
"Rename folder": "Renommer le dossier",
|
||||||
"Search: {query}": "Recherche : {query}",
|
"Search: {query}": "Recherche : {query}",
|
||||||
"No conversation selected": "Aucune conversation sélectionnée",
|
"No conversation selected": "Aucune conversation sélectionnée",
|
||||||
|
"No message selected": "Aucun message sélectionné",
|
||||||
"Drop here for the top level": "Déposer ici pour le niveau supérieur",
|
"Drop here for the top level": "Déposer ici pour le niveau supérieur",
|
||||||
|
|
||||||
// ── Longer prose ───────────────────────────────────────────────────
|
// ── Longer prose ───────────────────────────────────────────────────
|
||||||
@@ -820,6 +956,7 @@ export const catalog: Catalog = {
|
|||||||
"Open the Mail view to see all shortcuts.": "Ouvrez la vue E-mail pour voir tous les raccourcis.",
|
"Open the Mail view to see all shortcuts.": "Ouvrez la vue E-mail pour voir tous les raccourcis.",
|
||||||
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Les raccourcis façon Gmail sont toujours actifs. Appuyez sur {key} n'importe où pour afficher cette liste.",
|
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Les raccourcis façon Gmail sont toujours actifs. Appuyez sur {key} n'importe où pour afficher cette liste.",
|
||||||
"Select a conversation to read it here · Press {key} for shortcuts": "Sélectionnez une conversation pour la lire ici · {key} pour les raccourcis",
|
"Select a conversation to read it here · Press {key} for shortcuts": "Sélectionnez une conversation pour la lire ici · {key} pour les raccourcis",
|
||||||
|
"Select a message to read it here · Press {key} for shortcuts": "Sélectionnez un message pour le lire ici · {key} pour les raccourcis",
|
||||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Astuce : appuyez sur {key} sur une conversation pour appliquer des libellés. Recherchez avec {operator}.",
|
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Astuce : appuyez sur {key} sur une conversation pour appliquer des libellés. Recherchez avec {operator}.",
|
||||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Un webmail libre, rapide et agréable pour {server}, bâti sur JMAP.",
|
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Un webmail libre, rapide et agréable pour {server}, bâti sur JMAP.",
|
||||||
"Defaults for the calendar views and new events.": "Valeurs par défaut des vues d'agenda et des nouveaux événements.",
|
"Defaults for the calendar views and new events.": "Valeurs par défaut des vues d'agenda et des nouveaux événements.",
|
||||||
@@ -861,11 +998,12 @@ export const catalog: Catalog = {
|
|||||||
"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 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.",
|
||||||
"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}.",
|
||||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} est la palette de {site}, et celle d'un nouveau compte. C'est un thème sombre : il compte donc comme sombre partout où cela importe, et la couleur d'accentuation ci-dessous s'y applique toujours.",
|
|
||||||
"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.",
|
"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.",
|
||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Nouveau message",
|
"New message": "Nouveau message",
|
||||||
|
"New mail": "Nouveau courrier",
|
||||||
|
"Could not do that — open ihasmail and try again": "Impossible : ouvrez ihasmail et réessayez",
|
||||||
"Sending…": "Envoi…",
|
"Sending…": "Envoi…",
|
||||||
"Saving…": "Enregistrement…",
|
"Saving…": "Enregistrement…",
|
||||||
"Error": "Erreur",
|
"Error": "Erreur",
|
||||||
@@ -906,6 +1044,7 @@ export const catalog: Catalog = {
|
|||||||
"Could not copy the address": "Impossible de copier l’adresse",
|
"Could not copy the address": "Impossible de copier l’adresse",
|
||||||
"Could not empty folder: {error}": "Impossible de vider le dossier : {error}",
|
"Could not empty folder: {error}": "Impossible de vider le dossier : {error}",
|
||||||
"Could not load source: {error}": "Impossible de charger la source : {error}",
|
"Could not load source: {error}": "Impossible de charger la source : {error}",
|
||||||
|
"Could not share: {error}": "Impossible de partager : {error}",
|
||||||
"Could not mark as read: {error}": "Impossible de marquer comme lu : {error}",
|
"Could not mark as read: {error}": "Impossible de marquer comme lu : {error}",
|
||||||
"Could not save draft: {error}": "Impossible d’enregistrer le brouillon : {error}",
|
"Could not save draft: {error}": "Impossible d’enregistrer le brouillon : {error}",
|
||||||
"Could not save filter: {error}": "Impossible d’enregistrer le filtre : {error}",
|
"Could not save filter: {error}": "Impossible d’enregistrer le filtre : {error}",
|
||||||
@@ -1114,7 +1253,7 @@ export const catalog: Catalog = {
|
|||||||
"Date received": "Date de réception",
|
"Date received": "Date de réception",
|
||||||
"Date sent": "Date d'envoi",
|
"Date sent": "Date d'envoi",
|
||||||
"Day view": "Vue jour",
|
"Day view": "Vue jour",
|
||||||
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula, Gruvbox, Rosé Pine et Tokyo Night sont l'œuvre de leurs propres projets et sont utilisés sous licence MIT ; les nuances entre leurs couleurs publiées en sont dérivées, et chacune est vérifiée pour le contraste. La couleur d'accent ci-dessous s'applique toujours par-dessus n'importe laquelle d'entre elles.",
|
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "Les palettes portant le nom d'un autre projet sont l'œuvre de ce projet et sont utilisées sous sa propre licence ; les nuances intermédiaires entre leurs couleurs publiées sont dérivées, et chacune est vérifiée pour le contraste. La couleur d'accentuation ci-dessous s'applique toujours par-dessus n'importe laquelle d'entre elles.",
|
||||||
"Earlier": "Plus tôt",
|
"Earlier": "Plus tôt",
|
||||||
"Every folder": "Tous les dossiers",
|
"Every folder": "Tous les dossiers",
|
||||||
"Everyone addressed will receive this.": "Tous les destinataires le recevront.",
|
"Everyone addressed will receive this.": "Tous les destinataires le recevront.",
|
||||||
@@ -1215,6 +1354,14 @@ export const catalog: Catalog = {
|
|||||||
"Throw away your changes?": "Abandonner vos modifications ?",
|
"Throw away your changes?": "Abandonner vos modifications ?",
|
||||||
"Today, in your date format": "Aujourd'hui, dans votre format de date",
|
"Today, in your date format": "Aujourd'hui, dans votre format de date",
|
||||||
"Unread first": "Les non lus d'abord",
|
"Unread first": "Les non lus d'abord",
|
||||||
|
"Read first": "Les lus d'abord",
|
||||||
|
"Unstarred first": "Les non suivis d'abord",
|
||||||
|
"Smallest first": "Les moins volumineux d'abord",
|
||||||
|
"Z to A": "De Z à A",
|
||||||
|
"A to Z": "De A à Z",
|
||||||
|
"It reads {shown} but goes to {actual}.": "Il affiche {shown} mais mène à {actual}.",
|
||||||
|
"The full address is {href}.": "L'adresse complète est {href}.",
|
||||||
|
"This message came from {domain}, which is outside your organisation.": "Ce message provient de {domain}, qui est extérieur à votre organisation.",
|
||||||
"Unsaved changes": "Modifications non enregistrées",
|
"Unsaved changes": "Modifications non enregistrées",
|
||||||
"View as": "Afficher comme",
|
"View as": "Afficher comme",
|
||||||
"Warnings": "Avertissements",
|
"Warnings": "Avertissements",
|
||||||
@@ -1260,8 +1407,119 @@ export const catalog: Catalog = {
|
|||||||
"This message was sent automatically, so no read receipt is offered.": "Ce message a été envoyé automatiquement, aucun accusé de lecture n'est donc proposé.",
|
"This message was sent automatically, so no read receipt is offered.": "Ce message a été envoyé automatiquement, aucun accusé de lecture n'est donc proposé.",
|
||||||
"This server will not hold a message longer than {span}.": "Ce serveur ne retient pas un message plus de {span}.",
|
"This server will not hold a message longer than {span}.": "Ce serveur ne retient pas un message plus de {span}.",
|
||||||
"Upload failed": "Échec de l'envoi",
|
"Upload failed": "Échec de l'envoi",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
"Every {n} {frequency}": "Tous les {n} {frequency}",
|
||||||
|
"Monthly": "Chaque mois",
|
||||||
|
"Monthly on day {days}": "Chaque mois le {days}",
|
||||||
|
"Monthly on the {ordinal} {weekday}": "Chaque mois le {ordinal} {weekday}",
|
||||||
|
"Monthly on {weekday}": "Chaque mois le {weekday}",
|
||||||
|
"Weekly": "Chaque semaine",
|
||||||
|
"Weekly on {days}": "Chaque semaine le {days}",
|
||||||
|
"add {flag}": "ajouter {flag}",
|
||||||
|
"always": "toujours",
|
||||||
|
"body contains \"{value}\"": "le corps contient \"{value}\"",
|
||||||
|
"body does not contain \"{value}\"": "le corps ne contient pas \"{value}\"",
|
||||||
|
"delete it": "le supprimer",
|
||||||
|
"fifth": "cinquième",
|
||||||
|
"first": "premier",
|
||||||
|
"forward to {address}": "transférer à {address}",
|
||||||
|
"fourth": "quatrième",
|
||||||
|
"keep it": "le conserver",
|
||||||
|
"last": "dernier",
|
||||||
|
"mark it read": "le marquer comme lu",
|
||||||
|
"move to {folder}": "déplacer vers {folder}",
|
||||||
|
"reject it": "le rejeter",
|
||||||
|
"remove {flag}": "retirer {flag}",
|
||||||
|
"second": "deuxième",
|
||||||
|
"size is over {n} KB": "la taille dépasse {n} Ko",
|
||||||
|
"size is under {n} KB": "la taille est inférieure à {n} Ko",
|
||||||
|
"star it": "le suivre",
|
||||||
|
"stop": "arrêter",
|
||||||
|
"third": "troisième",
|
||||||
|
"{header} address {op} \"{value}\"": "l'adresse {header} {op} \"{value}\"",
|
||||||
|
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
|
||||||
|
"{rule}, until {date}": "{rule}, jusqu'au {date}",
|
||||||
|
"{tests} → {actions}": "{tests} → {actions}",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"This cannot be undone.": "Cette action est irréversible.",
|
||||||
|
"Some could not be deleted: {error}": "Certains n’ont pas pu être supprimés : {error}",
|
||||||
|
"It was not deleted": "Il n’a pas été supprimé",
|
||||||
|
"Empty address book": "Vider ce carnet d’adresses",
|
||||||
|
"There is nothing in it to delete": "Il n’y a rien à supprimer dedans",
|
||||||
|
"Empty “{name}”?": "Vider « {name} » ?",
|
||||||
|
"Delete them": "Les supprimer",
|
||||||
|
"Nothing was deleted": "Rien n’a été supprimé",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "Le certificat couvre",
|
||||||
|
"Details": "Détails",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Les messages précédents de cette adresse étaient signés par {previous}. Celui-ci est signé par {current}.",
|
||||||
|
"Fingerprint": "Empreinte",
|
||||||
|
"Hide details": "Masquer les détails",
|
||||||
|
"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 uses a signature algorithm ihasmail cannot check yet.": "Il utilise un algorithme de signature qu'ihasmail 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.",
|
||||||
|
"Previous fingerprint": "Empreinte précédente",
|
||||||
|
"Signed at": "Signé le",
|
||||||
|
"Signed by {name} — the same signer as before.": "Signé par {name} — le même signataire que précédemment.",
|
||||||
|
"Signed by {name}, seen here for the first time.": "Signé par {name}, vu ici pour la première fois.",
|
||||||
|
"Signer": "Signataire",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Cela peut signifier un certificat renouvelé, comme cela peut être quelqu'un d'autre. Vérifiez avec la personne par un autre moyen avant de faire confiance.",
|
||||||
|
"The certificate has expired.": "Le certificat a expiré.",
|
||||||
|
"The certificate is not valid yet.": "Le certificat n'est pas encore valide.",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Le message ne correspond pas à ce qui a été signé : il a été modifié après signature, ou abîmé en chemin.",
|
||||||
|
"The signature carries no certificate that can be read.": "La signature ne contient aucun certificat lisible.",
|
||||||
|
"The signature could not be read.": "La signature n'a pas pu être lue.",
|
||||||
|
"The signature does not match the certificate sent with it.": "La signature ne correspond pas au certificat envoyé avec elle.",
|
||||||
|
"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 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 signature does not check out.": "Cette signature ne tient pas.",
|
||||||
|
"Valid until": "Valable jusqu'au",
|
||||||
|
"a different certificate": "un certificat différent",
|
||||||
|
"an unnamed signer": "un signataire sans nom",
|
||||||
|
"as claimed by the signer": "selon le signataire",
|
||||||
|
"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.",
|
||||||
|
"itself, or an issuer it does not name": "lui-même, ou un émetteur qu'il ne nomme pas",
|
||||||
|
"no address": "aucune adresse",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── 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." },
|
||||||
|
"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." },
|
||||||
|
"{n} domains": { one: "{n} domaine", other: "{n} domaines" },
|
||||||
|
"{n} mailing lists": { one: "{n} liste de diffusion", other: "{n} listes de diffusion" },
|
||||||
|
"{n} DKIM keys": { one: "{n} clé DKIM", other: "{n} clés DKIM" },
|
||||||
|
"{n} other items": { one: "{n} autre élément", other: "{n} autres éléments" },
|
||||||
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
"{n} accounts": { one: "{n} compte", other: "{n} comptes" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Delete {n} items": { one: "Supprimer {n} élément", other: "Supprimer {n} éléments" },
|
||||||
|
"Delete {n} items?": { one: "Supprimer {n} élément ?", other: "Supprimer {n} éléments ?" },
|
||||||
|
"Move {n} items": { one: "Déplacer {n} élément", other: "Déplacer {n} éléments" },
|
||||||
|
"Move {n} items…": { one: "Déplacer {n} élément…", other: "Déplacer {n} éléments…" },
|
||||||
|
"The event runs {n} days longer than this shows.": { one: "L'événement dure {n} jour de plus que ce qui est affiché ici.", other: "L'événement dure {n} jours de plus que ce qui est affiché ici." },
|
||||||
|
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} invité n'est pas sur ce serveur, il n'y a donc pas de disponibilité à lire pour lui.", other: "{n} invités ne sont pas sur ce serveur, il n'y a donc pas de disponibilité à lire pour eux." },
|
||||||
|
"{n} items selected": { one: "{n} élément sélectionné", other: "{n} éléments sélectionnés" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Every {n} days": { one: "Chaque jour", other: "Tous les {n} jours" },
|
||||||
|
"Every {n} months": { one: "Chaque mois", other: "Tous les {n} mois" },
|
||||||
|
"Every {n} months on day {days}": { one: "Chaque mois le {days}", other: "Tous les {n} mois le {days}" },
|
||||||
|
"Every {n} months on the {ordinal} {weekday}": { one: "Chaque mois le {ordinal} {weekday}", other: "Tous les {n} mois le {ordinal} {weekday}" },
|
||||||
|
"Every {n} months on {weekday}": { one: "Chaque mois le {weekday}", other: "Tous les {n} mois le {weekday}" },
|
||||||
|
"Every {n} weeks": { one: "Chaque semaine", other: "Toutes les {n} semaines" },
|
||||||
|
"Every {n} weeks on {days}": { one: "Chaque semaine le {days}", other: "Toutes les {n} semaines le {days}" },
|
||||||
|
"Every {n} years": { one: "Chaque année", other: "Tous les {n} ans" },
|
||||||
|
"{rule}, {n} times": { one: "{rule}, {n} fois", other: "{rule}, {n} fois" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
"Move {n} messages to Trash?": { one: "Déplacer {n} message vers la Corbeille ?", other: "Déplacer {n} messages vers la Corbeille ?" },
|
"Move {n} messages to Trash?": { one: "Déplacer {n} message vers la Corbeille ?", other: "Déplacer {n} messages vers la Corbeille ?" },
|
||||||
"{n} days": { one: "{n} jour", other: "{n} jours" },
|
"{n} days": { one: "{n} jour", other: "{n} jours" },
|
||||||
@@ -1273,8 +1531,7 @@ export const catalog: Catalog = {
|
|||||||
"Your administrator changed {n} settings": { one: "Votre administration a modifié {n} paramètre", other: "Votre administration a modifié {n} paramètres" },
|
"Your administrator changed {n} settings": { one: "Votre administration a modifié {n} paramètre", other: "Votre administration a modifié {n} paramètres" },
|
||||||
"Exported {n} events": { one: "{n} événement exporté", other: "{n} événements exportés" },
|
"Exported {n} events": { one: "{n} événement exporté", other: "{n} événements exportés" },
|
||||||
"Imported {n} events": { one: "{n} événement importé", other: "{n} événements importés" },
|
"Imported {n} events": { one: "{n} événement importé", other: "{n} événements importés" },
|
||||||
"Already here: {n} events, nothing imported": { one: "Déjà présent : {n} événement, rien d’importé", other: "Déjà présents : {n} événements, rien d’importé" },
|
"Updated {n} events, nothing new": { one: "{n} événement mis à jour, rien de nouveau", other: "{n} événements mis à jour, rien de nouveau" },
|
||||||
"{n} were already here": { one: "{n} était déjà présent", other: "{n} étaient déjà présents" },
|
|
||||||
"{n} messages": { one: "{n} message", other: "{n} messages" },
|
"{n} messages": { one: "{n} message", other: "{n} messages" },
|
||||||
"{n} selected": { one: "{n} sélectionné", other: "{n} sélectionnés" },
|
"{n} selected": { one: "{n} sélectionné", other: "{n} sélectionnés" },
|
||||||
"{n} conversations": { one: "{n} conversation", other: "{n} conversations" },
|
"{n} conversations": { one: "{n} conversation", other: "{n} conversations" },
|
||||||
@@ -1293,5 +1550,10 @@ export const catalog: Catalog = {
|
|||||||
"Marked {n} messages as read": { one: "{n} message marqué comme lu", other: "{n} messages marqués comme lus" },
|
"Marked {n} messages as read": { one: "{n} message marqué comme lu", other: "{n} messages marqués comme lus" },
|
||||||
"in {n} folders": { one: "dans {n} dossier", other: "dans {n} dossiers" },
|
"in {n} folders": { one: "dans {n} dossier", other: "dans {n} dossiers" },
|
||||||
"Deleted {n} messages": { one: "{n} message supprimé", other: "{n} messages supprimés" },
|
"Deleted {n} messages": { one: "{n} message supprimé", other: "{n} messages supprimés" },
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"Delete {n} contacts?": { one: "Supprimer {n} contact ?", other: "Supprimer {n} contacts ?" },
|
||||||
|
"Deleted {n} contacts": { one: "{n} contact supprimé", other: "{n} contacts supprimés" },
|
||||||
|
"{n} contacts will be deleted. This cannot be undone.": { one: "{n} contact sera supprimé. Cette action est irréversible.", other: "{n} contacts seront supprimés. Cette action est irréversible." },
|
||||||
|
"{n} were also in other address books and were only removed from this one": { one: "{n} contact se trouvait aussi dans un autre carnet d’adresses et n’a été retiré que de celui-ci", other: "{n} contacts se trouvaient aussi dans d’autres carnets d’adresses et n’ont été retirés que de celui-ci" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+275
-13
@@ -46,6 +46,146 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
strings: {
|
||||||
|
// ── Administration: domains ────────────────────────────────────
|
||||||
|
"Domains": "ドメイン",
|
||||||
|
"By hand": "手動",
|
||||||
|
"Signing": "署名中",
|
||||||
|
"Published, not signing yet": "公開済み(まだ署名なし)",
|
||||||
|
"Retiring": "廃止中",
|
||||||
|
"Retired": "廃止済み",
|
||||||
|
"This domain no longer exists. Someone may have removed it.": "このドメインはもう存在しません。誰かが削除した可能性があります。",
|
||||||
|
"That doesn't look like a domain name, such as example.com.": "example.com のようなドメイン名ではないようです。",
|
||||||
|
"Added {name}. Its DNS records are ready to copy.": "{name} を追加しました。DNS レコードをコピーできます。",
|
||||||
|
"Saved {name}": "{name} を保存しました",
|
||||||
|
"Add domain": "ドメインを追加",
|
||||||
|
"Added {date}": "{date} に追加",
|
||||||
|
"This domain is disabled on the server.": "このドメインはサーバーで無効になっています。",
|
||||||
|
"Your role lets you view domains but not change them.": "あなたのロールでは、ドメインの閲覧はできますが変更はできません。",
|
||||||
|
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "新しいドメインは、サーバーが作成・更新する DKIM 鍵でメールに署名します。DNS レコードはドメインを追加するとここに表示されます。",
|
||||||
|
"Other names": "別名",
|
||||||
|
"Delivery": "配信",
|
||||||
|
"Catch-all address": "キャッチオールアドレス",
|
||||||
|
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "このドメインで誰も持っていないアドレス宛てのメールをここに配信します。空欄にすると、そのメールは拒否されます。",
|
||||||
|
"Plus addressing": "プラスアドレス",
|
||||||
|
"Set by a custom rule on the server.": "サーバーのカスタムルールで設定されています。",
|
||||||
|
"Mail to name+anything@ is delivered to name@.": "名前+任意@ 宛てのメールは 名前@ に配信されます。",
|
||||||
|
"DNS records": "DNS レコード",
|
||||||
|
"Published automatically through {provider}.": "{provider} を通じて自動で公開されます。",
|
||||||
|
"Published automatically by the server.": "サーバーが自動で公開します。",
|
||||||
|
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "このドメインの DNS を管理している場所に追加してください。追加されるまで、メールは配信されず信頼もされません。",
|
||||||
|
"Copy {type} record for {name}": "{name} の {type} レコードをコピー",
|
||||||
|
"Copy value": "値をコピー",
|
||||||
|
"Copied the zone file": "ゾーンファイルをコピーしました",
|
||||||
|
"Copy all as a zone file": "すべてゾーンファイルとしてコピー",
|
||||||
|
"The server returned no records for this domain.": "サーバーはこのドメインのレコードを返しませんでした。",
|
||||||
|
"DKIM keys": "DKIM 鍵",
|
||||||
|
"The server creates and rotates these keys itself.": "サーバーがこれらの鍵を自動で作成・更新します。",
|
||||||
|
"These keys are managed by hand on the server.": "これらの鍵はサーバーで手動管理されています。",
|
||||||
|
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "DKIM 鍵がないため、このドメインのメールは署名されず、迷惑メールと判定されやすくなります。",
|
||||||
|
"Managed by the server": "サーバーによる管理",
|
||||||
|
"Certificate": "証明書",
|
||||||
|
"Another name for this domain": "このドメインの別名",
|
||||||
|
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "これらのいずれの名前でも、同じアドレス宛てのメールは同じアカウントに届きます。変更は保存時に反映されます。",
|
||||||
|
"Its DKIM keys have to be removed first, and your role can't remove them.": "先に DKIM 鍵を削除する必要がありますが、あなたのロールでは削除できません。",
|
||||||
|
"The server stops accepting mail for this domain.": "サーバーはこのドメイン宛てのメールを受け付けなくなります。",
|
||||||
|
"Remove domain…": "ドメインを削除…",
|
||||||
|
"Remove {name}?": "{name} を削除しますか?",
|
||||||
|
"Removed {name}": "{name} を削除しました",
|
||||||
|
"The server kept the domain: it is still used by {things}.": "サーバーはドメインを削除しませんでした。まだ {things} が使用しています。",
|
||||||
|
"Remove domain": "ドメインを削除",
|
||||||
|
"The server stops accepting mail for this domain. This can't be undone.": "サーバーはこのドメイン宛てのメールを受け付けなくなります。元に戻すことはできません。",
|
||||||
|
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "アドレスの置き場所と、メールが届き信頼されるための DNS レコードです。",
|
||||||
|
"Search domains": "ドメインを検索",
|
||||||
|
"No domains match": "一致するドメインはありません",
|
||||||
|
"No domains yet": "ドメインはまだありません",
|
||||||
|
"DKIM": "DKIM",
|
||||||
|
"Tenant": "テナント",
|
||||||
|
"Disabled": "無効",
|
||||||
|
"also {names}": "別名: {names}",
|
||||||
|
"The server did not say whether the domain was created.": "ドメインが作成されたかどうか、サーバーから応答がありませんでした。",
|
||||||
|
// ── Administration: refusals ───────────────────────────────────
|
||||||
|
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "有効なドメイン名ではありません。example.com のように、実在するトップレベルドメインの名前を使ってください。",
|
||||||
|
"That isn't a valid email address. Use a full address, such as [email protected].": "有効なメールアドレスではありません。[email protected] のような完全なアドレスを使ってください。",
|
||||||
|
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "有効なアドレスではありません。@ の前には英数字、ドット、ハイフン、アンダースコアを使ってください。",
|
||||||
|
"That isn't a valid host name or IP address.": "有効なホスト名または IP アドレスではありません。",
|
||||||
|
"A required value was left empty.": "必須の値が空欄です。",
|
||||||
|
"Administration is turned off on this installation.": "このインストールでは管理機能が無効になっています。",
|
||||||
|
"The mail server could not carry out the request ({code}).": "メールサーバーはリクエストを実行できませんでした({code})。",
|
||||||
|
"You can't give an account permissions your own role doesn't have.": "自分のロールにない権限をアカウントに付与することはできません。",
|
||||||
|
"This account signs in through an external directory, so its password can't be set here.": "このアカウントは外部ディレクトリでサインインするため、ここではパスワードを設定できません。",
|
||||||
|
"The server's licence allows no more accounts.": "サーバーのライセンスでは、これ以上アカウントを追加できません。",
|
||||||
|
"That domain name is already in use on this server, as a domain or another domain's other name.": "このドメイン名は、ドメインまたは別のドメインの別名としてこのサーバーで既に使われています。",
|
||||||
|
"Your organisation has reached the number of domains it is allowed.": "組織で許可されているドメイン数の上限に達しました。",
|
||||||
|
"That is more than the mail server accepts in one change.": "メールサーバーが一度の変更で受け付けられる量を超えています。",
|
||||||
|
"The mail server rejected one of the values. Check what you entered and try again.": "メールサーバーが値のひとつを拒否しました。入力内容を確認して、もう一度お試しください。",
|
||||||
|
"The mail server refused the change ({code}).": "メールサーバーが変更を拒否しました({code})。",
|
||||||
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
|
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "自分のデバイスとして指定した端末でのみ使えます。「これは自分のデバイスです」にチェックを入れて、もう一度サインインしてください。",
|
||||||
|
"Change your own password in {settings}.": "ご自身のパスワードは{settings}で変更してください。",
|
||||||
|
"Administration": "管理",
|
||||||
|
"Directory": "ディレクトリ",
|
||||||
|
"User": "ユーザー",
|
||||||
|
"Administrator": "管理者",
|
||||||
|
"Custom role": "カスタムロール",
|
||||||
|
"New account": "新しいアカウント",
|
||||||
|
"The people who sign in to mail on the domains you manage.": "管理しているドメインでメールにサインインするユーザーです。",
|
||||||
|
"Search by name or address": "名前またはアドレスで検索",
|
||||||
|
"Search accounts": "アカウントを検索",
|
||||||
|
"No accounts match": "一致するアカウントはありません",
|
||||||
|
"No accounts yet": "アカウントはまだありません",
|
||||||
|
"Nothing on your domains matches “{query}”.": "ドメイン内に「{query}」と一致するものはありません。",
|
||||||
|
"Open {address}": "{address} を開く",
|
||||||
|
"{from}–{to} of {total}": "{from}–{to} / {total}",
|
||||||
|
"Previous page": "前のページ",
|
||||||
|
"Next page": "次のページ",
|
||||||
|
"Storage": "ストレージ",
|
||||||
|
"Groups": "グループ",
|
||||||
|
"{used} · no limit": "{used} · 上限なし",
|
||||||
|
"Profile": "プロフィール",
|
||||||
|
"Domain": "ドメイン",
|
||||||
|
"No domains are available to create an account on.": "アカウントを作成できるドメインがありません。",
|
||||||
|
"Sign-in": "サインイン",
|
||||||
|
"Other addresses": "その他のアドレス",
|
||||||
|
"Not in any group": "どのグループにも属していません",
|
||||||
|
"You can't change your own role.": "自分のロールは変更できません。",
|
||||||
|
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "ご自身が持つ権限だけで構成されたロールのみ表示されます。テナント内のアカウントでは、管理者はそのテナントの管理者を意味します。",
|
||||||
|
"Limit in GB": "上限(GB)",
|
||||||
|
"No limit": "上限なし",
|
||||||
|
"This account has permissions yours doesn't, so you can view it but not change it.": "このアカウントにはあなたのアカウントにない権限があるため、閲覧はできますが変更はできません。",
|
||||||
|
"Your role lets you view accounts but not change them.": "あなたのロールでは、アカウントの閲覧はできますが変更はできません。",
|
||||||
|
"This account has permissions yours doesn't.": "このアカウントにはあなたのアカウントにない権限があります。",
|
||||||
|
"You can't delete the account you're signed in with.": "サインイン中のアカウントは削除できません。",
|
||||||
|
"Create account": "アカウントを作成",
|
||||||
|
"An account needs an address.": "アカウントにはアドレスが必要です。",
|
||||||
|
"Created {address}": "{address} を作成しました",
|
||||||
|
"Saved {address}": "{address} を保存しました",
|
||||||
|
"Generate a password": "パスワードを生成",
|
||||||
|
"Pass it on some way other than email to this address.": "このアドレス宛てのメール以外の方法で伝えてください。",
|
||||||
|
"This account has no password. It may sign in through a directory or single sign-on.": "このアカウントにはパスワードがありません。ディレクトリやシングルサインオンでサインインしている可能性があります。",
|
||||||
|
"Set a new password…": "新しいパスワードを設定…",
|
||||||
|
"{name} will be signed out of every app and device using the old password.": "{name} は、古いパスワードを使っているすべてのアプリとデバイスからサインアウトされます。",
|
||||||
|
"New password set for {address}": "{address} の新しいパスワードを設定しました",
|
||||||
|
"Set password": "パスワードを設定",
|
||||||
|
"Remove {address}": "{address} を削除",
|
||||||
|
"New address": "新しいアドレス",
|
||||||
|
"another name": "別の名前",
|
||||||
|
"Mail to these addresses is delivered to this account. Changes apply when you save.": "これらのアドレス宛てのメールはこのアカウントに配信されます。変更は保存時に反映されます。",
|
||||||
|
"Deletes the mailbox and everything in it.": "メールボックスとその中身をすべて削除します。",
|
||||||
|
"Delete account…": "アカウントを削除…",
|
||||||
|
"Delete {address}?": "{address} を削除しますか?",
|
||||||
|
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "このアカウントのメール、カレンダー、連絡先、ファイルが削除されます。サーバーがバックグラウンドで削除し、元に戻すことはできません。",
|
||||||
|
"Type {address} to confirm": "確認のため {address} と入力してください",
|
||||||
|
"Delete account": "アカウントを削除",
|
||||||
|
"Deleted {address}": "{address} を削除しました",
|
||||||
|
"The server did not say whether the account was created.": "アカウントが作成されたかどうか、サーバーから応答がありませんでした。",
|
||||||
|
"The mail server refused this. Your role may not allow it.": "メールサーバーに拒否されました。ロールで許可されていない可能性があります。",
|
||||||
|
"That address is already in use on this server, as an account, a list or an alias.": "このアドレスは、アカウント、リスト、またはエイリアスとして、このサーバーですでに使われています。",
|
||||||
|
"One of the chosen domain, role or group can't be used for this account.": "選択したドメイン、ロール、またはグループはこのアカウントには使えません。",
|
||||||
|
"Your organisation has reached the number of accounts it is allowed.": "組織で許可されているアカウント数の上限に達しました。",
|
||||||
|
"Something still depends on this, so the server kept it.": "まだこれに依存しているものがあるため、サーバーは削除しませんでした。",
|
||||||
|
"This account no longer exists. Someone may have deleted it.": "このアカウントはもう存在しません。誰かが削除した可能性があります。",
|
||||||
|
"The password was not accepted: {reason}": "パスワードは受け付けられませんでした: {reason}",
|
||||||
|
"The password was not accepted.": "パスワードは受け付けられませんでした。",
|
||||||
"Go to folder…": "フォルダーへ移動…",
|
"Go to folder…": "フォルダーへ移動…",
|
||||||
"Set for everyone here. You cannot change this.": "この環境全体で設定されています。変更できません。",
|
"Set for everyone here. You cannot change this.": "この環境全体で設定されています。変更できません。",
|
||||||
"Export iCAL file": "iCAL ファイルをエクスポート",
|
"Export iCAL file": "iCAL ファイルをエクスポート",
|
||||||
@@ -318,7 +458,6 @@ export const catalog: Catalog = {
|
|||||||
"Busy": "予定あり",
|
"Busy": "予定あり",
|
||||||
"Free/busy": "空き時間",
|
"Free/busy": "空き時間",
|
||||||
"Show as": "表示方法",
|
"Show as": "表示方法",
|
||||||
"Availability on {date}": "{date} の空き状況",
|
|
||||||
"Count all events as busy": "すべての予定を「予定あり」とする",
|
"Count all events as busy": "すべての予定を「予定あり」とする",
|
||||||
"Only events I'm attending": "参加する予定のみ",
|
"Only events I'm attending": "参加する予定のみ",
|
||||||
"Don't include in availability": "空き状況に含めない",
|
"Don't include in availability": "空き状況に含めない",
|
||||||
@@ -357,7 +496,6 @@ export const catalog: Catalog = {
|
|||||||
"New address book": "新しいアドレス帳",
|
"New address book": "新しいアドレス帳",
|
||||||
"No address books yet.": "アドレス帳がまだありません。",
|
"No address books yet.": "アドレス帳がまだありません。",
|
||||||
"Choose from address books": "アドレス帳から選択",
|
"Choose from address books": "アドレス帳から選択",
|
||||||
"Import vCard": "vCard をインポート",
|
|
||||||
"Export all contacts": "すべての連絡先をエクスポート",
|
"Export all contacts": "すべての連絡先をエクスポート",
|
||||||
"Export address book": "このアドレス帳をエクスポート",
|
"Export address book": "このアドレス帳をエクスポート",
|
||||||
"Import contacts…": "連絡先をインポート…",
|
"Import contacts…": "連絡先をインポート…",
|
||||||
@@ -432,7 +570,6 @@ export const catalog: Catalog = {
|
|||||||
"Make ihasmail yours.": "ihasmail を自分好みに整えましょう。",
|
"Make ihasmail yours.": "ihasmail を自分好みに整えましょう。",
|
||||||
"Reading": "閲覧",
|
"Reading": "閲覧",
|
||||||
"Reading pane": "プレビューウィンドウ",
|
"Reading pane": "プレビューウィンドウ",
|
||||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "閲覧・送信・一覧の動作。設定はこのブラウザーに保存されます。",
|
|
||||||
"Right of the list": "一覧の右",
|
"Right of the list": "一覧の右",
|
||||||
"Below the list": "一覧の下",
|
"Below the list": "一覧の下",
|
||||||
"Hidden (open full width)": "表示しない(全幅で開く)",
|
"Hidden (open full width)": "表示しない(全幅で開く)",
|
||||||
@@ -475,10 +612,6 @@ export const catalog: Catalog = {
|
|||||||
"Time zone": "タイムゾーン",
|
"Time zone": "タイムゾーン",
|
||||||
"Week starts on": "週の始まり",
|
"Week starts on": "週の始まり",
|
||||||
"Monday": "月曜日",
|
"Monday": "月曜日",
|
||||||
"Tuesday": "火曜日",
|
|
||||||
"Wednesday": "水曜日",
|
|
||||||
"Thursday": "木曜日",
|
|
||||||
"Friday": "金曜日",
|
|
||||||
"Saturday": "土曜日",
|
"Saturday": "土曜日",
|
||||||
"Sunday": "日曜日",
|
"Sunday": "日曜日",
|
||||||
"12-hour clock (6:23 PM)": "12 時間制 (6:23 PM)",
|
"12-hour clock (6:23 PM)": "12 時間制 (6:23 PM)",
|
||||||
@@ -518,6 +651,8 @@ export const catalog: Catalog = {
|
|||||||
"Show labels in the sidebar": "サイドバーにラベルを表示する",
|
"Show labels in the sidebar": "サイドバーにラベルを表示する",
|
||||||
"Collapse sidebar to icons": "サイドバーをアイコンだけにする",
|
"Collapse sidebar to icons": "サイドバーをアイコンだけにする",
|
||||||
"Apply the theme to messages too": "メールにもテーマを適用する",
|
"Apply the theme to messages too": "メールにもテーマを適用する",
|
||||||
|
"Apply it even to mail that styles itself": "自分で配色を持つメールにも適用する",
|
||||||
|
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "宣伝メールや領収メールはほとんどがどこかで色を指定しているため、上の設定では大半が白いカードのままになります。これを有効にすると、送信者の配色の上からテーマを適用します。メッセージが載っている背景は取り除き、ボタンや色付きのバナーは文字が読めるようにそのまま残します。一部のメールは元の見た目を保てないため、別の設定として分けています。",
|
||||||
"Swiping": "スワイプ操作",
|
"Swiping": "スワイプ操作",
|
||||||
"Swipe left": "左へスワイプ",
|
"Swipe left": "左へスワイプ",
|
||||||
"Swipe right": "右へスワイプ",
|
"Swipe right": "右へスワイプ",
|
||||||
@@ -727,12 +862,10 @@ export const catalog: Catalog = {
|
|||||||
"Manage labels": "ラベルを管理",
|
"Manage labels": "ラベルを管理",
|
||||||
"Create “{name}”": "「{name}」を作成",
|
"Create “{name}”": "「{name}」を作成",
|
||||||
"Type a name to create your first label.": "名前を入力すると、最初のラベルを作成できます。",
|
"Type a name to create your first label.": "名前を入力すると、最初のラベルを作成できます。",
|
||||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "ラベルはメールに保存される IMAP キーワードなので、他のクライアントにも同期されます。名前と色はこのブラウザーに保存されます。",
|
|
||||||
"New label": "新しいラベル",
|
"New label": "新しいラベル",
|
||||||
"Delete label": "ラベルを削除",
|
"Delete label": "ラベルを削除",
|
||||||
|
|
||||||
// ── Attachments, dates, search prose ───────────────────────────────
|
// ── Attachments, dates, search prose ───────────────────────────────
|
||||||
"PDF": "PDF",
|
|
||||||
"Large attachments may be rejected by some servers": "大きな添付ファイルは、サーバーによっては拒否されることがあります",
|
"Large attachments may be rejected by some servers": "大きな添付ファイルは、サーバーによっては拒否されることがあります",
|
||||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "画像は「ファイル」内(フォルダー「ihasmail」)に保存され、送信時にメールへ埋め込まれます。",
|
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "画像は「ファイル」内(フォルダー「ihasmail」)に保存され、送信時にメールへ埋め込まれます。",
|
||||||
"After": "以降",
|
"After": "以降",
|
||||||
@@ -745,6 +878,7 @@ export const catalog: Catalog = {
|
|||||||
"Open the Mail view to see all shortcuts.": "すべてのショートカットはメール画面で確認できます。",
|
"Open the Mail view to see all shortcuts.": "すべてのショートカットはメール画面で確認できます。",
|
||||||
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Gmail 形式のショートカットは常に有効です。どこでも {key} を押すとこの一覧を表示します。",
|
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Gmail 形式のショートカットは常に有効です。どこでも {key} を押すとこの一覧を表示します。",
|
||||||
"Select a conversation to read it here · Press {key} for shortcuts": "スレッドを選ぶとここに表示されます · {key} でショートカット一覧",
|
"Select a conversation to read it here · Press {key} for shortcuts": "スレッドを選ぶとここに表示されます · {key} でショートカット一覧",
|
||||||
|
"Select a message to read it here · Press {key} for shortcuts": "メールを選ぶとここに表示されます · {key} でショートカット一覧",
|
||||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "ヒント: スレッド上で {key} を押すとラベルを付けられます。検索には {operator} が使えます。",
|
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "ヒント: スレッド上で {key} を押すとラベルを付けられます。検索には {operator} が使えます。",
|
||||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "{server} のための、軽快で使いやすいオープンソースのウェブメール。JMAP で動作します。",
|
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "{server} のための、軽快で使いやすいオープンソースのウェブメール。JMAP で動作します。",
|
||||||
|
|
||||||
@@ -806,7 +940,6 @@ export const catalog: Catalog = {
|
|||||||
"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 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 以降が必要で、それより古いサーバーへのサインインは拒否されます。",
|
||||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} は {site} の配色で、新しいアカウントの初期テーマです。ダークテーマなので、明暗が問われる場面ではダークとして扱われます。下のアクセントカラーはその上に重ねて適用されます。",
|
|
||||||
"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 に関する情報をあえて含めていません。このビルドがサーバーに求めるものは、上の行に示されています。",
|
"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 に関する情報をあえて含めていません。このビルドがサーバーに求めるものは、上の行に示されています。",
|
||||||
|
|
||||||
// ── Constant labels ────────────────────────────────────────────────
|
// ── Constant labels ────────────────────────────────────────────────
|
||||||
@@ -841,6 +974,7 @@ export const catalog: Catalog = {
|
|||||||
"Not spam": "迷惑メールではない",
|
"Not spam": "迷惑メールではない",
|
||||||
"Nothing": "何もしない",
|
"Nothing": "何もしない",
|
||||||
"No conversation selected": "スレッドが選択されていません",
|
"No conversation selected": "スレッドが選択されていません",
|
||||||
|
"No message selected": "メールが選択されていません",
|
||||||
"Drop here for the top level": "ここにドロップすると最上位へ移動します",
|
"Drop here for the top level": "ここにドロップすると最上位へ移動します",
|
||||||
"Later today": "今日のうちに",
|
"Later today": "今日のうちに",
|
||||||
"Tomorrow morning": "明日の朝",
|
"Tomorrow morning": "明日の朝",
|
||||||
@@ -859,6 +993,8 @@ export const catalog: Catalog = {
|
|||||||
"folder\u0004Junk Mail": "迷惑メール",
|
"folder\u0004Junk Mail": "迷惑メール",
|
||||||
"folder\u0004Important": "重要",
|
"folder\u0004Important": "重要",
|
||||||
"folder\u0004All mail": "すべてのメール",
|
"folder\u0004All mail": "すべてのメール",
|
||||||
|
"share sheet\u0004Share": "共有",
|
||||||
|
"share sheet\u0004Share…": "共有…",
|
||||||
"folder": "フォルダー",
|
"folder": "フォルダー",
|
||||||
"“{name}” moved into “{parent}”": "「{name}」を「{parent}」に移動しました",
|
"“{name}” moved into “{parent}”": "「{name}」を「{parent}」に移動しました",
|
||||||
"“{name}” moved to the top level": "「{name}」を最上位に移動しました",
|
"“{name}” moved to the top level": "「{name}」を最上位に移動しました",
|
||||||
@@ -869,6 +1005,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "新規メール",
|
"New message": "新規メール",
|
||||||
|
"New mail": "新着メール",
|
||||||
|
"Could not do that — open ihasmail and try again": "実行できませんでした - ihasmail を開いてやり直してください",
|
||||||
"Sending…": "送信中…",
|
"Sending…": "送信中…",
|
||||||
"Saving…": "保存中…",
|
"Saving…": "保存中…",
|
||||||
"Error": "エラー",
|
"Error": "エラー",
|
||||||
@@ -909,6 +1047,7 @@ export const catalog: Catalog = {
|
|||||||
"Could not copy the address": "アドレスをコピーできませんでした",
|
"Could not copy the address": "アドレスをコピーできませんでした",
|
||||||
"Could not empty folder: {error}": "フォルダーを空にできませんでした: {error}",
|
"Could not empty folder: {error}": "フォルダーを空にできませんでした: {error}",
|
||||||
"Could not load source: {error}": "ソースを読み込めませんでした: {error}",
|
"Could not load source: {error}": "ソースを読み込めませんでした: {error}",
|
||||||
|
"Could not share: {error}": "共有できませんでした: {error}",
|
||||||
"Could not mark as read: {error}": "既読にできませんでした: {error}",
|
"Could not mark as read: {error}": "既読にできませんでした: {error}",
|
||||||
"Could not save draft: {error}": "下書きを保存できませんでした: {error}",
|
"Could not save draft: {error}": "下書きを保存できませんでした: {error}",
|
||||||
"Could not save filter: {error}": "フィルターを保存できませんでした: {error}",
|
"Could not save filter: {error}": "フィルターを保存できませんでした: {error}",
|
||||||
@@ -1117,7 +1256,7 @@ export const catalog: Catalog = {
|
|||||||
"Date received": "受信日時",
|
"Date received": "受信日時",
|
||||||
"Date sent": "送信日時",
|
"Date sent": "送信日時",
|
||||||
"Day view": "日表示",
|
"Day view": "日表示",
|
||||||
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula、Gruvbox、Rosé Pine、Tokyo Night はそれぞれのプロジェクトの成果物で、MIT ライセンスのもとで利用しています。公開された色の中間の階調は派生させたもので、いずれもコントラストを確認しています。下のアクセントカラーはどの配色の上にも適用されます。",
|
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "他のプロジェクトの名前が付いたパレットは、そのプロジェクトの成果物であり、そのプロジェクト自身のライセンスのもとで使用しています。公開されている色の中間の階調は派生させたもので、いずれもコントラストを検証しています。下のアクセントカラーは、どのパレットの上にも適用されます。",
|
||||||
"Earlier": "これより前",
|
"Earlier": "これより前",
|
||||||
"Every folder": "すべてのフォルダー",
|
"Every folder": "すべてのフォルダー",
|
||||||
"Everyone addressed will receive this.": "宛先の全員がこれを受け取ります。",
|
"Everyone addressed will receive this.": "宛先の全員がこれを受け取ります。",
|
||||||
@@ -1218,6 +1357,14 @@ export const catalog: Catalog = {
|
|||||||
"Throw away your changes?": "変更を破棄しますか?",
|
"Throw away your changes?": "変更を破棄しますか?",
|
||||||
"Today, in your date format": "今日(お使いの日付形式)",
|
"Today, in your date format": "今日(お使いの日付形式)",
|
||||||
"Unread first": "未読を先頭に",
|
"Unread first": "未読を先頭に",
|
||||||
|
"Read first": "既読を先頭に",
|
||||||
|
"Unstarred first": "スターなしを先頭に",
|
||||||
|
"Smallest first": "サイズの小さい順",
|
||||||
|
"Z to A": "Z→A の順",
|
||||||
|
"A to Z": "A→Z の順",
|
||||||
|
"It reads {shown} but goes to {actual}.": "表示は {shown} ですが、実際のリンク先は {actual} です。",
|
||||||
|
"The full address is {href}.": "完全なアドレスは {href} です。",
|
||||||
|
"This message came from {domain}, which is outside your organisation.": "このメッセージは組織外の {domain} から届いています。",
|
||||||
"Unsaved changes": "保存されていない変更",
|
"Unsaved changes": "保存されていない変更",
|
||||||
"View as": "表示形式",
|
"View as": "表示形式",
|
||||||
"Warnings": "警告",
|
"Warnings": "警告",
|
||||||
@@ -1263,8 +1410,119 @@ export const catalog: Catalog = {
|
|||||||
"This message was sent automatically, so no read receipt is offered.": "このメールは自動送信されたため、開封確認は行いません。",
|
"This message was sent automatically, so no read receipt is offered.": "このメールは自動送信されたため、開封確認は行いません。",
|
||||||
"This server will not hold a message longer than {span}.": "このサーバーはメールを {span} を超えて保留しません。",
|
"This server will not hold a message longer than {span}.": "このサーバーはメールを {span} を超えて保留しません。",
|
||||||
"Upload failed": "アップロードに失敗しました",
|
"Upload failed": "アップロードに失敗しました",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
"Every {n} {frequency}": "{n} {frequency} ごと",
|
||||||
|
"Monthly": "毎月",
|
||||||
|
"Monthly on day {days}": "毎月 {days} 日",
|
||||||
|
"Monthly on the {ordinal} {weekday}": "毎月第{ordinal} {weekday}",
|
||||||
|
"Monthly on {weekday}": "毎月 {weekday}",
|
||||||
|
"Weekly": "毎週",
|
||||||
|
"Weekly on {days}": "毎週 {days}",
|
||||||
|
"add {flag}": "{flag} を付ける",
|
||||||
|
"always": "常に",
|
||||||
|
"body contains \"{value}\"": "本文が \"{value}\" を含む",
|
||||||
|
"body does not contain \"{value}\"": "本文が \"{value}\" を含まない",
|
||||||
|
"delete it": "削除",
|
||||||
|
"fifth": "5",
|
||||||
|
"first": "1",
|
||||||
|
"forward to {address}": "{address} に転送",
|
||||||
|
"fourth": "4",
|
||||||
|
"keep it": "保持",
|
||||||
|
"last": "最終",
|
||||||
|
"mark it read": "既読にする",
|
||||||
|
"move to {folder}": "{folder} に移動",
|
||||||
|
"reject it": "拒否",
|
||||||
|
"remove {flag}": "{flag} を外す",
|
||||||
|
"second": "2",
|
||||||
|
"size is over {n} KB": "サイズが {n} KB を超える",
|
||||||
|
"size is under {n} KB": "サイズが {n} KB 未満",
|
||||||
|
"star it": "スターを付ける",
|
||||||
|
"stop": "停止",
|
||||||
|
"third": "3",
|
||||||
|
"{header} address {op} \"{value}\"": "{header} のアドレスが \"{value}\" を{op}",
|
||||||
|
"{header} {op} \"{value}\"": "{header} が \"{value}\" を{op}",
|
||||||
|
"{rule}, until {date}": "{rule}({date} まで)",
|
||||||
|
"{tests} → {actions}": "{tests} → {actions}",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"This cannot be undone.": "この操作は取り消せません。",
|
||||||
|
"Some could not be deleted: {error}": "一部を削除できませんでした: {error}",
|
||||||
|
"It was not deleted": "削除されませんでした",
|
||||||
|
"Empty address book": "このアドレス帳を空にする",
|
||||||
|
"There is nothing in it to delete": "削除するものがありません",
|
||||||
|
"Empty “{name}”?": "「{name}」を空にしますか?",
|
||||||
|
"Delete them": "削除する",
|
||||||
|
"Nothing was deleted": "何も削除されませんでした",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "証明書の対象",
|
||||||
|
"Details": "詳細",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "このアドレスからの以前のメールは {previous} が署名していました。このメールの署名者は {current} です。",
|
||||||
|
"Fingerprint": "フィンガープリント",
|
||||||
|
"Hide details": "詳細を隠す",
|
||||||
|
"Issued by": "発行者",
|
||||||
|
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "OpenPGP で署名されており、ihasmail には送信者の公開鍵を取得する手段がありません。",
|
||||||
|
"It uses a signature algorithm ihasmail cannot check yet.": "ihasmail がまだ検証できない署名アルゴリズムが使われています。",
|
||||||
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "{name} の証明書で署名されており、この証明書はこのアドレスを対象にしていません。",
|
||||||
|
"Previous fingerprint": "以前のフィンガープリント",
|
||||||
|
"Signed at": "署名日時",
|
||||||
|
"Signed by {name} — the same signer as before.": "{name} による署名です。以前と同じ署名者です。",
|
||||||
|
"Signed by {name}, seen here for the first time.": "{name} による署名です。ここで見るのは初めてです。",
|
||||||
|
"Signer": "署名者",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "証明書を更新しただけの場合もあれば、別人の場合もあります。信頼する前に、別の手段で本人に確認してください。",
|
||||||
|
"The certificate has expired.": "証明書の有効期限が切れています。",
|
||||||
|
"The certificate is not valid yet.": "証明書はまだ有効ではありません。",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "メールが署名された内容と一致しません。署名後に変更されたか、途中で壊れています。",
|
||||||
|
"The signature carries no certificate that can be read.": "署名に読み取れる証明書が含まれていません。",
|
||||||
|
"The signature could not be read.": "署名を読み取れませんでした。",
|
||||||
|
"The signature does not match the certificate sent with it.": "署名が、一緒に送られた証明書と一致しません。",
|
||||||
|
"The signature is not for this sender.": "この署名はこの送信者のものではありません。",
|
||||||
|
"The signed part is missing either the message or the signature.": "署名された部分に、本文か署名のどちらかが欠けています。",
|
||||||
|
"The signer has changed.": "署名者が変わりました。",
|
||||||
|
"This message is signed, and ihasmail could not check the signature.": "このメールには署名がありますが、ihasmail は署名を検証できませんでした。",
|
||||||
|
"This signature does not check out.": "この署名は正しくありません。",
|
||||||
|
"Valid until": "有効期限",
|
||||||
|
"a different certificate": "別の証明書",
|
||||||
|
"an unnamed signer": "名前のない署名者",
|
||||||
|
"as claimed by the signer": "署名者の申告による",
|
||||||
|
"first seen {date}": "初回は {date}",
|
||||||
|
"ihasmail will tell you if a later message from this address is signed by anybody else.": "このアドレスからの以降のメールが別の人の署名だった場合、ihasmail がお知らせします。",
|
||||||
|
"itself, or an issuer it does not name": "自分自身、または名前のない発行者",
|
||||||
|
"no address": "アドレスなし",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── Administration: domains ────────────────────────────────────
|
||||||
|
"{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 鍵も削除されます。元に戻すことはできません。" },
|
||||||
|
"{n} domains": { other: "{n} 件のドメイン" },
|
||||||
|
"{n} mailing lists": { other: "{n} 件のメーリングリスト" },
|
||||||
|
"{n} DKIM keys": { other: "{n} 個の DKIM 鍵" },
|
||||||
|
"{n} other items": { other: "その他 {n} 件" },
|
||||||
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
"{n} accounts": { other: "{n} 件のアカウント" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Delete {n} items": { other: "{n} 件を削除" },
|
||||||
|
"Delete {n} items?": { other: "{n} 件を削除しますか?" },
|
||||||
|
"Move {n} items": { other: "{n} 件を移動" },
|
||||||
|
"Move {n} items…": { other: "{n} 件を移動…" },
|
||||||
|
"The event runs {n} days longer than this shows.": { other: "この予定は表示よりも {n} 日長く続きます。" },
|
||||||
|
"{n} guests are not on this server, so there is no free/busy to read for them.": { other: "{n} 名の参加者はこのサーバーにいないため、空き情報を取得できません。" },
|
||||||
|
"{n} items selected": { other: "{n} 件を選択中" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Every {n} days": { other: "{n} 日ごと" },
|
||||||
|
"Every {n} months": { other: "{n} か月ごと" },
|
||||||
|
"Every {n} months on day {days}": { other: "{n} か月ごと {days} 日" },
|
||||||
|
"Every {n} months on the {ordinal} {weekday}": { other: "{n} か月ごと第{ordinal} {weekday}" },
|
||||||
|
"Every {n} months on {weekday}": { other: "{n} か月ごと {weekday}" },
|
||||||
|
"Every {n} weeks": { other: "{n} 週ごと" },
|
||||||
|
"Every {n} weeks on {days}": { other: "{n} 週ごと {days}" },
|
||||||
|
"Every {n} years": { other: "{n} 年ごと" },
|
||||||
|
"{rule}, {n} times": { other: "{rule}({n} 回)" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
"Move {n} messages to Trash?": { other: "{n} 通のメールをゴミ箱に移動しますか?" },
|
"Move {n} messages to Trash?": { other: "{n} 通のメールをゴミ箱に移動しますか?" },
|
||||||
"{n} days": { other: "{n} 日" },
|
"{n} days": { other: "{n} 日" },
|
||||||
@@ -1276,8 +1534,7 @@ export const catalog: Catalog = {
|
|||||||
"Your administrator changed {n} settings": { other: "管理者が {n} 件の設定を変更しました" },
|
"Your administrator changed {n} settings": { other: "管理者が {n} 件の設定を変更しました" },
|
||||||
"Exported {n} events": { other: "{n} 件の予定をエクスポートしました" },
|
"Exported {n} events": { other: "{n} 件の予定をエクスポートしました" },
|
||||||
"Imported {n} events": { other: "{n} 件の予定をインポートしました" },
|
"Imported {n} events": { other: "{n} 件の予定をインポートしました" },
|
||||||
"Already here: {n} events, nothing imported": { other: "すでに存在: {n} 件、インポートなし" },
|
"Updated {n} events, nothing new": { other: "{n} 件の予定を更新しました。新規はありません" },
|
||||||
"{n} were already here": { other: "{n} 件はすでに存在していました" },
|
|
||||||
/*
|
/*
|
||||||
* One form each, because Japanese has one. Intl.PluralRules returns
|
* One form each, because Japanese has one. Intl.PluralRules returns
|
||||||
* `other` for every number, so `one`, `few` and `many` would never be
|
* `other` for every number, so `one`, `few` and `many` would never be
|
||||||
@@ -1301,5 +1558,10 @@ export const catalog: Catalog = {
|
|||||||
"Marked {n} messages as read": { other: "{n} 通のメールを既読にしました" },
|
"Marked {n} messages as read": { other: "{n} 通のメールを既読にしました" },
|
||||||
"in {n} folders": { other: "{n} 個のフォルダーで" },
|
"in {n} folders": { other: "{n} 個のフォルダーで" },
|
||||||
"Deleted {n} messages": { other: "{n} 通のメールを削除しました" },
|
"Deleted {n} messages": { other: "{n} 通のメールを削除しました" },
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"Delete {n} contacts?": { other: "{n} 件の連絡先を削除しますか?" },
|
||||||
|
"Deleted {n} contacts": { other: "{n} 件の連絡先を削除しました" },
|
||||||
|
"{n} contacts will be deleted. This cannot be undone.": { other: "{n} 件の連絡先が削除されます。この操作は取り消せません。" },
|
||||||
|
"{n} were also in other address books and were only removed from this one": { other: "{n} 件は他のアドレス帳にもあるため、このアドレス帳から外しただけです" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+275
-13
@@ -43,6 +43,146 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
strings: {
|
||||||
|
// ── Administration: domains ────────────────────────────────────
|
||||||
|
"Domains": "Domeinen",
|
||||||
|
"By hand": "Handmatig",
|
||||||
|
"Signing": "Ondertekent",
|
||||||
|
"Published, not signing yet": "Gepubliceerd, ondertekent nog niet",
|
||||||
|
"Retiring": "Wordt uitgefaseerd",
|
||||||
|
"Retired": "Uitgefaseerd",
|
||||||
|
"This domain no longer exists. Someone may have removed it.": "Dit domein bestaat niet meer. Mogelijk heeft iemand het verwijderd.",
|
||||||
|
"That doesn't look like a domain name, such as example.com.": "Dat lijkt niet op een domeinnaam, zoals example.com.",
|
||||||
|
"Added {name}. Its DNS records are ready to copy.": "{name} toegevoegd. De DNS-records staan klaar om te kopiëren.",
|
||||||
|
"Saved {name}": "{name} opgeslagen",
|
||||||
|
"Add domain": "Domein toevoegen",
|
||||||
|
"Added {date}": "Toegevoegd op {date}",
|
||||||
|
"This domain is disabled on the server.": "Dit domein is op de server uitgeschakeld.",
|
||||||
|
"Your role lets you view domains but not change them.": "Met uw rol kunt u domeinen bekijken, maar niet wijzigen.",
|
||||||
|
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Nieuwe domeinen ondertekenen hun e-mail met DKIM-sleutels die de server aanmaakt en vervangt. De DNS-records verschijnen hier zodra het domein is toegevoegd.",
|
||||||
|
"Other names": "Andere namen",
|
||||||
|
"Delivery": "Bezorging",
|
||||||
|
"Catch-all address": "Catch-alladres",
|
||||||
|
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "E-mail aan een adres dat niemand op dit domein heeft, wordt hier bezorgd. Laat leeg om die e-mail te weigeren.",
|
||||||
|
"Plus addressing": "Plus-adressering",
|
||||||
|
"Set by a custom rule on the server.": "Ingesteld door een aangepaste regel op de server.",
|
||||||
|
"Mail to name+anything@ is delivered to name@.": "E-mail aan naam+wat-dan-ook@ wordt bezorgd bij naam@.",
|
||||||
|
"DNS records": "DNS-records",
|
||||||
|
"Published automatically through {provider}.": "Automatisch gepubliceerd via {provider}.",
|
||||||
|
"Published automatically by the server.": "Automatisch gepubliceerd door de server.",
|
||||||
|
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Voeg deze toe waar de DNS van dit domein wordt beheerd. Tot die tijd wordt e-mail niet bezorgd of vertrouwd.",
|
||||||
|
"Copy {type} record for {name}": "{type}-record voor {name} kopiëren",
|
||||||
|
"Copy value": "Waarde kopiëren",
|
||||||
|
"Copied the zone file": "Zonebestand gekopieerd",
|
||||||
|
"Copy all as a zone file": "Alles als zonebestand kopiëren",
|
||||||
|
"The server returned no records for this domain.": "De server gaf geen records voor dit domein.",
|
||||||
|
"DKIM keys": "DKIM-sleutels",
|
||||||
|
"The server creates and rotates these keys itself.": "De server maakt en vervangt deze sleutels zelf.",
|
||||||
|
"These keys are managed by hand on the server.": "Deze sleutels worden op de server handmatig beheerd.",
|
||||||
|
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "Geen DKIM-sleutels: e-mail van dit domein wordt niet ondertekend en komt eerder in de spam terecht.",
|
||||||
|
"Managed by the server": "Beheerd door de server",
|
||||||
|
"Certificate": "Certificaat",
|
||||||
|
"Another name for this domain": "Andere naam voor dit domein",
|
||||||
|
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "E-mail aan hetzelfde adres onder een van deze namen komt in hetzelfde account. Wijzigingen gelden na opslaan.",
|
||||||
|
"Its DKIM keys have to be removed first, and your role can't remove them.": "De DKIM-sleutels moeten eerst worden verwijderd, en uw rol mag dat niet.",
|
||||||
|
"The server stops accepting mail for this domain.": "De server accepteert geen e-mail meer voor dit domein.",
|
||||||
|
"Remove domain…": "Domein verwijderen…",
|
||||||
|
"Remove {name}?": "{name} verwijderen?",
|
||||||
|
"Removed {name}": "{name} verwijderd",
|
||||||
|
"The server kept the domain: it is still used by {things}.": "De server heeft het domein behouden: het wordt nog gebruikt door {things}.",
|
||||||
|
"Remove domain": "Domein verwijderen",
|
||||||
|
"The server stops accepting mail for this domain. This can't be undone.": "De server accepteert geen e-mail meer voor dit domein. Dit kan niet ongedaan worden gemaakt.",
|
||||||
|
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Waar uw adressen wonen, en de DNS-records waardoor e-mail aankomt en wordt vertrouwd.",
|
||||||
|
"Search domains": "Domeinen zoeken",
|
||||||
|
"No domains match": "Geen domeinen gevonden",
|
||||||
|
"No domains yet": "Nog geen domeinen",
|
||||||
|
"DKIM": "DKIM",
|
||||||
|
"Tenant": "Tenant",
|
||||||
|
"Disabled": "Uitgeschakeld",
|
||||||
|
"also {names}": "ook {names}",
|
||||||
|
"The server did not say whether the domain was created.": "De server heeft niet gemeld of het domein is aangemaakt.",
|
||||||
|
// ── Administration: refusals ───────────────────────────────────
|
||||||
|
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Dat is geen geldige domeinnaam. Gebruik een naam zoals example.com, met een echt topleveldomein.",
|
||||||
|
"That isn't a valid email address. Use a full address, such as [email protected].": "Dat is geen geldig e-mailadres. Gebruik een volledig adres, zoals [email protected].",
|
||||||
|
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Dat is geen geldig adres. Gebruik letters, cijfers, punten, koppeltekens of underscores vóór de @.",
|
||||||
|
"That isn't a valid host name or IP address.": "Dat is geen geldige hostnaam of IP-adres.",
|
||||||
|
"A required value was left empty.": "Een verplichte waarde is leeg gelaten.",
|
||||||
|
"Administration is turned off on this installation.": "Beheer is uitgeschakeld in deze installatie.",
|
||||||
|
"The mail server could not carry out the request ({code}).": "De mailserver kon het verzoek niet uitvoeren ({code}).",
|
||||||
|
"You can't give an account permissions your own role doesn't have.": "U kunt een account geen rechten geven die uw eigen rol niet heeft.",
|
||||||
|
"This account signs in through an external directory, so its password can't be set here.": "Dit account logt in via een externe adreslijst, dus het wachtwoord kan hier niet worden ingesteld.",
|
||||||
|
"The server's licence allows no more accounts.": "De licentie van de server staat geen extra accounts toe.",
|
||||||
|
"That domain name is already in use on this server, as a domain or another domain's other name.": "Die domeinnaam is op deze server al in gebruik, als domein of als andere naam van een ander domein.",
|
||||||
|
"Your organisation has reached the number of domains it is allowed.": "Uw organisatie heeft het toegestane aantal domeinen bereikt.",
|
||||||
|
"That is more than the mail server accepts in one change.": "Dat is meer dan de mailserver in één wijziging accepteert.",
|
||||||
|
"The mail server rejected one of the values. Check what you entered and try again.": "De mailserver heeft een van de waarden geweigerd. Controleer wat u hebt ingevuld en probeer het opnieuw.",
|
||||||
|
"The mail server refused the change ({code}).": "De mailserver heeft de wijziging geweigerd ({code}).",
|
||||||
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
|
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Alleen op een apparaat dat u als uw eigen apparaat hebt aangemerkt. Log opnieuw in met ‘Dit is mijn eigen apparaat’ aangevinkt.",
|
||||||
|
"Change your own password in {settings}.": "Wijzig uw eigen wachtwoord bij {settings}.",
|
||||||
|
"Administration": "Beheer",
|
||||||
|
"Directory": "Adreslijst",
|
||||||
|
"User": "Gebruiker",
|
||||||
|
"Administrator": "Beheerder",
|
||||||
|
"Custom role": "Aangepaste rol",
|
||||||
|
"New account": "Nieuw account",
|
||||||
|
"The people who sign in to mail on the domains you manage.": "De mensen die op de domeinen die u beheert inloggen op hun e-mail.",
|
||||||
|
"Search by name or address": "Zoeken op naam of adres",
|
||||||
|
"Search accounts": "Accounts zoeken",
|
||||||
|
"No accounts match": "Geen accounts gevonden",
|
||||||
|
"No accounts yet": "Nog geen accounts",
|
||||||
|
"Nothing on your domains matches “{query}”.": "Niets op uw domeinen komt overeen met ‘{query}’.",
|
||||||
|
"Open {address}": "{address} openen",
|
||||||
|
"{from}–{to} of {total}": "{from}–{to} van {total}",
|
||||||
|
"Previous page": "Vorige pagina",
|
||||||
|
"Next page": "Volgende pagina",
|
||||||
|
"Storage": "Opslag",
|
||||||
|
"Groups": "Groepen",
|
||||||
|
"{used} · no limit": "{used} · geen limiet",
|
||||||
|
"Profile": "Profiel",
|
||||||
|
"Domain": "Domein",
|
||||||
|
"No domains are available to create an account on.": "Er is geen domein beschikbaar om een account op aan te maken.",
|
||||||
|
"Sign-in": "Inloggen",
|
||||||
|
"Other addresses": "Andere adressen",
|
||||||
|
"Not in any group": "Geen lid van een groep",
|
||||||
|
"You can't change your own role.": "U kunt uw eigen rol niet wijzigen.",
|
||||||
|
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Alleen rollen waarvan u de rechten zelf hebt, worden aangeboden. Bij een account binnen een tenant betekent Beheerder: beheerder van die tenant.",
|
||||||
|
"Limit in GB": "Limiet in GB",
|
||||||
|
"No limit": "Geen limiet",
|
||||||
|
"This account has permissions yours doesn't, so you can view it but not change it.": "Dit account heeft rechten die het uwe niet heeft. U kunt het bekijken, maar niet wijzigen.",
|
||||||
|
"Your role lets you view accounts but not change them.": "Met uw rol kunt u accounts bekijken, maar niet wijzigen.",
|
||||||
|
"This account has permissions yours doesn't.": "Dit account heeft rechten die het uwe niet heeft.",
|
||||||
|
"You can't delete the account you're signed in with.": "U kunt het account waarmee u bent ingelogd niet verwijderen.",
|
||||||
|
"Create account": "Account aanmaken",
|
||||||
|
"An account needs an address.": "Een account heeft een adres nodig.",
|
||||||
|
"Created {address}": "{address} aangemaakt",
|
||||||
|
"Saved {address}": "{address} opgeslagen",
|
||||||
|
"Generate a password": "Wachtwoord genereren",
|
||||||
|
"Pass it on some way other than email to this address.": "Geef het door op een andere manier dan per e-mail naar dit adres.",
|
||||||
|
"This account has no password. It may sign in through a directory or single sign-on.": "Dit account heeft geen wachtwoord. Mogelijk logt het in via een adreslijst of single sign-on.",
|
||||||
|
"Set a new password…": "Nieuw wachtwoord instellen…",
|
||||||
|
"{name} will be signed out of every app and device using the old password.": "{name} wordt uitgelogd in alle apps en op alle apparaten die het oude wachtwoord gebruiken.",
|
||||||
|
"New password set for {address}": "Nieuw wachtwoord ingesteld voor {address}",
|
||||||
|
"Set password": "Wachtwoord instellen",
|
||||||
|
"Remove {address}": "{address} verwijderen",
|
||||||
|
"New address": "Nieuw adres",
|
||||||
|
"another name": "andere naam",
|
||||||
|
"Mail to these addresses is delivered to this account. Changes apply when you save.": "E-mail aan deze adressen wordt in dit account afgeleverd. Wijzigingen gelden na opslaan.",
|
||||||
|
"Deletes the mailbox and everything in it.": "Verwijdert de mailbox en alles erin.",
|
||||||
|
"Delete account…": "Account verwijderen…",
|
||||||
|
"Delete {address}?": "{address} verwijderen?",
|
||||||
|
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Hiermee worden de e-mail, agenda’s, contacten en bestanden in dit account verwijderd. De server wist ze op de achtergrond en dit kan niet ongedaan worden gemaakt.",
|
||||||
|
"Type {address} to confirm": "Typ {address} om te bevestigen",
|
||||||
|
"Delete account": "Account verwijderen",
|
||||||
|
"Deleted {address}": "{address} verwijderd",
|
||||||
|
"The server did not say whether the account was created.": "De server heeft niet gemeld of het account is aangemaakt.",
|
||||||
|
"The mail server refused this. Your role may not allow it.": "De mailserver heeft dit geweigerd. Uw rol staat het mogelijk niet toe.",
|
||||||
|
"That address is already in use on this server, as an account, a list or an alias.": "Dat adres is op deze server al in gebruik, als account, lijst of alias.",
|
||||||
|
"One of the chosen domain, role or group can't be used for this account.": "Het gekozen domein, de rol of de groep kan niet voor dit account worden gebruikt.",
|
||||||
|
"Your organisation has reached the number of accounts it is allowed.": "Uw organisatie heeft het toegestane aantal accounts bereikt.",
|
||||||
|
"Something still depends on this, so the server kept it.": "Er hangt nog iets van af, dus de server heeft het behouden.",
|
||||||
|
"This account no longer exists. Someone may have deleted it.": "Dit account bestaat niet meer. Mogelijk heeft iemand het verwijderd.",
|
||||||
|
"The password was not accepted: {reason}": "Het wachtwoord is niet geaccepteerd: {reason}",
|
||||||
|
"The password was not accepted.": "Het wachtwoord is niet geaccepteerd.",
|
||||||
"Go to folder…": "Ga naar map…",
|
"Go to folder…": "Ga naar map…",
|
||||||
"Set for everyone here. You cannot change this.": "Hier voor iedereen ingesteld. U kunt dit niet wijzigen.",
|
"Set for everyone here. You cannot change this.": "Hier voor iedereen ingesteld. U kunt dit niet wijzigen.",
|
||||||
"Export iCAL file": "iCAL-bestand exporteren",
|
"Export iCAL file": "iCAL-bestand exporteren",
|
||||||
@@ -315,7 +455,6 @@ export const catalog: Catalog = {
|
|||||||
"Busy": "Bezet",
|
"Busy": "Bezet",
|
||||||
"Free/busy": "Vrij/bezet",
|
"Free/busy": "Vrij/bezet",
|
||||||
"Show as": "Weergeven als",
|
"Show as": "Weergeven als",
|
||||||
"Availability on {date}": "Beschikbaarheid op {date}",
|
|
||||||
"Count all events as busy": "Alle afspraken als bezet tellen",
|
"Count all events as busy": "Alle afspraken als bezet tellen",
|
||||||
"Only events I'm attending": "Alleen afspraken waaraan ik deelneem",
|
"Only events I'm attending": "Alleen afspraken waaraan ik deelneem",
|
||||||
"Don't include in availability": "Niet meetellen voor beschikbaarheid",
|
"Don't include in availability": "Niet meetellen voor beschikbaarheid",
|
||||||
@@ -354,7 +493,6 @@ export const catalog: Catalog = {
|
|||||||
"New address book": "Nieuw adresboek",
|
"New address book": "Nieuw adresboek",
|
||||||
"No address books yet.": "Nog geen adresboeken.",
|
"No address books yet.": "Nog geen adresboeken.",
|
||||||
"Choose from address books": "Kiezen uit adresboeken",
|
"Choose from address books": "Kiezen uit adresboeken",
|
||||||
"Import vCard": "vCard importeren",
|
|
||||||
"Export all contacts": "Alle contacten exporteren",
|
"Export all contacts": "Alle contacten exporteren",
|
||||||
"Export address book": "Dit adresboek exporteren",
|
"Export address book": "Dit adresboek exporteren",
|
||||||
"Import contacts…": "Contacten importeren…",
|
"Import contacts…": "Contacten importeren…",
|
||||||
@@ -429,7 +567,6 @@ export const catalog: Catalog = {
|
|||||||
"Make ihasmail yours.": "Maak ihasmail van uzelf.",
|
"Make ihasmail yours.": "Maak ihasmail van uzelf.",
|
||||||
"Reading": "Lezen",
|
"Reading": "Lezen",
|
||||||
"Reading pane": "Leesvenster",
|
"Reading pane": "Leesvenster",
|
||||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "Gedrag bij lezen, verzenden en in de lijst. De instellingen worden in deze browser bewaard.",
|
|
||||||
"Right of the list": "Rechts van de lijst",
|
"Right of the list": "Rechts van de lijst",
|
||||||
"Below the list": "Onder de lijst",
|
"Below the list": "Onder de lijst",
|
||||||
"Hidden (open full width)": "Verborgen (op volle breedte openen)",
|
"Hidden (open full width)": "Verborgen (op volle breedte openen)",
|
||||||
@@ -472,10 +609,6 @@ export const catalog: Catalog = {
|
|||||||
"Time zone": "Tijdzone",
|
"Time zone": "Tijdzone",
|
||||||
"Week starts on": "Week begint op",
|
"Week starts on": "Week begint op",
|
||||||
"Monday": "Maandag",
|
"Monday": "Maandag",
|
||||||
"Tuesday": "Dinsdag",
|
|
||||||
"Wednesday": "Woensdag",
|
|
||||||
"Thursday": "Donderdag",
|
|
||||||
"Friday": "Vrijdag",
|
|
||||||
"Saturday": "Zaterdag",
|
"Saturday": "Zaterdag",
|
||||||
"Sunday": "Zondag",
|
"Sunday": "Zondag",
|
||||||
"12-hour clock (6:23 PM)": "12-uursnotatie (6:23 PM)",
|
"12-hour clock (6:23 PM)": "12-uursnotatie (6:23 PM)",
|
||||||
@@ -515,6 +648,8 @@ export const catalog: Catalog = {
|
|||||||
"Show labels in the sidebar": "Labels in de zijbalk tonen",
|
"Show labels in the sidebar": "Labels in de zijbalk tonen",
|
||||||
"Collapse sidebar to icons": "Zijbalk inklappen tot pictogrammen",
|
"Collapse sidebar to icons": "Zijbalk inklappen tot pictogrammen",
|
||||||
"Apply the theme to messages too": "Thema ook op berichten toepassen",
|
"Apply the theme to messages too": "Thema ook op berichten toepassen",
|
||||||
|
"Apply it even to mail that styles itself": "Pas dit ook toe op e-mail met eigen vormgeving",
|
||||||
|
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Bijna alle reclame- en bonmail zet ergens een kleur, waardoor de instelling hierboven vrijwel alles op een witte kaart laat staan. Met deze optie wordt het thema over de kleuren van de afzender heen gelegd: achtergronden waarop het bericht is geplaatst vervallen, terwijl knoppen en gekleurde banners blijven staan zodat hun tekst leesbaar blijft. Sommige berichten overleven dat niet ongeschonden, en daarom is dit een aparte instelling.",
|
||||||
"Swiping": "Vegen",
|
"Swiping": "Vegen",
|
||||||
"Swipe left": "Naar links vegen",
|
"Swipe left": "Naar links vegen",
|
||||||
"Swipe right": "Naar rechts vegen",
|
"Swipe right": "Naar rechts vegen",
|
||||||
@@ -719,10 +854,8 @@ export const catalog: Catalog = {
|
|||||||
"Manage labels": "Labels beheren",
|
"Manage labels": "Labels beheren",
|
||||||
"Create “{name}”": "“{name}” maken",
|
"Create “{name}”": "“{name}” maken",
|
||||||
"Type a name to create your first label.": "Typ een naam om uw eerste label te maken.",
|
"Type a name to create your first label.": "Typ een naam om uw eerste label te maken.",
|
||||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Labels zijn IMAP-trefwoorden die in uw berichten worden opgeslagen en dus met andere clients synchroniseren. Namen en kleuren blijven in deze browser.",
|
|
||||||
"New label": "Nieuw label",
|
"New label": "Nieuw label",
|
||||||
"Delete label": "Label verwijderen",
|
"Delete label": "Label verwijderen",
|
||||||
"PDF": "PDF",
|
|
||||||
"Large attachments may be rejected by some servers": "Grote bijlagen worden door sommige servers geweigerd",
|
"Large attachments may be rejected by some servers": "Grote bijlagen worden door sommige servers geweigerd",
|
||||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Afbeeldingen worden opgeslagen in uw Bestanden (map “ihasmail”) en bij verzending ingesloten.",
|
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Afbeeldingen worden opgeslagen in uw Bestanden (map “ihasmail”) en bij verzending ingesloten.",
|
||||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Bedankt voor uw bericht. Ik ben afwezig tot … en reageer zodra ik terug ben.",
|
"Thanks for your message. I'm away until … and will reply when I'm back.": "Bedankt voor uw bericht. Ik ben afwezig tot … en reageer zodra ik terug ben.",
|
||||||
@@ -795,6 +928,8 @@ export const catalog: Catalog = {
|
|||||||
"folder\u0004Junk Mail": "Spam",
|
"folder\u0004Junk Mail": "Spam",
|
||||||
"folder\u0004Important": "Belangrijk",
|
"folder\u0004Important": "Belangrijk",
|
||||||
"folder\u0004All mail": "Alle berichten",
|
"folder\u0004All mail": "Alle berichten",
|
||||||
|
"share sheet\u0004Share": "Delen",
|
||||||
|
"share sheet\u0004Share…": "Delen…",
|
||||||
"folder": "map",
|
"folder": "map",
|
||||||
"“{name}” moved into “{parent}”": "“{name}” is verplaatst naar “{parent}”",
|
"“{name}” moved into “{parent}”": "“{name}” is verplaatst naar “{parent}”",
|
||||||
"“{name}” moved to the top level": "“{name}” is naar het hoogste niveau verplaatst",
|
"“{name}” moved to the top level": "“{name}” is naar het hoogste niveau verplaatst",
|
||||||
@@ -803,6 +938,7 @@ export const catalog: Catalog = {
|
|||||||
"Rename folder": "Map hernoemen",
|
"Rename folder": "Map hernoemen",
|
||||||
"Search: {query}": "Zoeken: {query}",
|
"Search: {query}": "Zoeken: {query}",
|
||||||
"No conversation selected": "Geen gesprek geselecteerd",
|
"No conversation selected": "Geen gesprek geselecteerd",
|
||||||
|
"No message selected": "Geen bericht geselecteerd",
|
||||||
"Drop here for the top level": "Hier neerzetten voor het hoogste niveau",
|
"Drop here for the top level": "Hier neerzetten voor het hoogste niveau",
|
||||||
|
|
||||||
// ── Longer prose ───────────────────────────────────────────────────
|
// ── Longer prose ───────────────────────────────────────────────────
|
||||||
@@ -811,6 +947,7 @@ export const catalog: Catalog = {
|
|||||||
"Open the Mail view to see all shortcuts.": "Open de E-mailweergave om alle sneltoetsen te zien.",
|
"Open the Mail view to see all shortcuts.": "Open de E-mailweergave om alle sneltoetsen te zien.",
|
||||||
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Sneltoetsen in Gmail-stijl staan altijd aan. Druk overal op {key} om deze lijst te zien.",
|
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Sneltoetsen in Gmail-stijl staan altijd aan. Druk overal op {key} om deze lijst te zien.",
|
||||||
"Select a conversation to read it here · Press {key} for shortcuts": "Selecteer een gesprek om het hier te lezen · {key} voor sneltoetsen",
|
"Select a conversation to read it here · Press {key} for shortcuts": "Selecteer een gesprek om het hier te lezen · {key} voor sneltoetsen",
|
||||||
|
"Select a message to read it here · Press {key} for shortcuts": "Selecteer een bericht om het hier te lezen · {key} voor sneltoetsen",
|
||||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tip: druk op {key} bij een gesprek om labels toe te wijzen. Zoek met {operator}.",
|
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Tip: druk op {key} bij een gesprek om labels toe te wijzen. Zoek met {operator}.",
|
||||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Een snelle, prettige, opensource webmail voor {server}, gebouwd op JMAP.",
|
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Een snelle, prettige, opensource webmail voor {server}, gebouwd op JMAP.",
|
||||||
"Defaults for the calendar views and new events.": "Standaardwaarden voor de agendaweergaven en nieuwe afspraken.",
|
"Defaults for the calendar views and new events.": "Standaardwaarden voor de agendaweergaven en nieuwe afspraken.",
|
||||||
@@ -852,11 +989,12 @@ export const catalog: Catalog = {
|
|||||||
"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 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.",
|
||||||
"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}.",
|
||||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} is het kleurenpalet van {site}, en waarmee een nieuw account begint. Het is een donker thema en telt dus overal als donker waar dat uitmaakt; de accentkleur hieronder werkt er nog steeds bovenop.",
|
|
||||||
"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.",
|
"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.",
|
||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Nieuw bericht",
|
"New message": "Nieuw bericht",
|
||||||
|
"New mail": "Nieuwe e-mail",
|
||||||
|
"Could not do that — open ihasmail and try again": "Dat lukte niet — open ihasmail en probeer het opnieuw",
|
||||||
"Sending…": "Bezig met verzenden…",
|
"Sending…": "Bezig met verzenden…",
|
||||||
"Saving…": "Bezig met opslaan…",
|
"Saving…": "Bezig met opslaan…",
|
||||||
"Error": "Fout",
|
"Error": "Fout",
|
||||||
@@ -897,6 +1035,7 @@ export const catalog: Catalog = {
|
|||||||
"Could not copy the address": "Het adres kon niet worden gekopieerd",
|
"Could not copy the address": "Het adres kon niet worden gekopieerd",
|
||||||
"Could not empty folder: {error}": "Map legen mislukt: {error}",
|
"Could not empty folder: {error}": "Map legen mislukt: {error}",
|
||||||
"Could not load source: {error}": "De bron kon niet worden geladen: {error}",
|
"Could not load source: {error}": "De bron kon niet worden geladen: {error}",
|
||||||
|
"Could not share: {error}": "Delen is niet gelukt: {error}",
|
||||||
"Could not mark as read: {error}": "Markeren als gelezen mislukt: {error}",
|
"Could not mark as read: {error}": "Markeren als gelezen mislukt: {error}",
|
||||||
"Could not save draft: {error}": "Concept opslaan mislukt: {error}",
|
"Could not save draft: {error}": "Concept opslaan mislukt: {error}",
|
||||||
"Could not save filter: {error}": "Filter opslaan mislukt: {error}",
|
"Could not save filter: {error}": "Filter opslaan mislukt: {error}",
|
||||||
@@ -1105,7 +1244,7 @@ export const catalog: Catalog = {
|
|||||||
"Date received": "Ontvangstdatum",
|
"Date received": "Ontvangstdatum",
|
||||||
"Date sent": "Verzenddatum",
|
"Date sent": "Verzenddatum",
|
||||||
"Day view": "Dagweergave",
|
"Day view": "Dagweergave",
|
||||||
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula, Gruvbox, Rosé Pine en Tokyo Night zijn het werk van hun eigen projecten en worden gebruikt onder de MIT-licentie; de tinten tussen hun gepubliceerde kleuren zijn daarvan 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 licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour 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.",
|
||||||
"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.": "Iedereen die is geadresseerd ontvangt dit.",
|
||||||
@@ -1206,6 +1345,14 @@ export const catalog: Catalog = {
|
|||||||
"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",
|
||||||
"Unread first": "Ongelezen eerst",
|
"Unread first": "Ongelezen eerst",
|
||||||
|
"Read first": "Gelezen eerst",
|
||||||
|
"Unstarred first": "Zonder ster eerst",
|
||||||
|
"Smallest first": "Kleinste eerst",
|
||||||
|
"Z to A": "Z tot A",
|
||||||
|
"A to Z": "A tot Z",
|
||||||
|
"It reads {shown} but goes to {actual}.": "Er staat {shown}, maar de link gaat naar {actual}.",
|
||||||
|
"The full address is {href}.": "Het volledige adres is {href}.",
|
||||||
|
"This message came from {domain}, which is outside your organisation.": "Dit bericht komt van {domain}, buiten uw organisatie.",
|
||||||
"Unsaved changes": "Niet-opgeslagen wijzigingen",
|
"Unsaved changes": "Niet-opgeslagen wijzigingen",
|
||||||
"View as": "Weergeven als",
|
"View as": "Weergeven als",
|
||||||
"Warnings": "Waarschuwingen",
|
"Warnings": "Waarschuwingen",
|
||||||
@@ -1251,8 +1398,119 @@ export const catalog: Catalog = {
|
|||||||
"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 houdt een bericht niet langer dan {span} vast.",
|
||||||
"Upload failed": "Uploaden mislukt",
|
"Upload failed": "Uploaden mislukt",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
"Every {n} {frequency}": "Elke {n} {frequency}",
|
||||||
|
"Monthly": "Maandelijks",
|
||||||
|
"Monthly on day {days}": "Maandelijks op dag {days}",
|
||||||
|
"Monthly on the {ordinal} {weekday}": "Maandelijks op de {ordinal} {weekday}",
|
||||||
|
"Monthly on {weekday}": "Maandelijks op {weekday}",
|
||||||
|
"Weekly": "Wekelijks",
|
||||||
|
"Weekly on {days}": "Wekelijks op {days}",
|
||||||
|
"add {flag}": "{flag} toevoegen",
|
||||||
|
"always": "altijd",
|
||||||
|
"body contains \"{value}\"": "tekst bevat \"{value}\"",
|
||||||
|
"body does not contain \"{value}\"": "tekst bevat niet \"{value}\"",
|
||||||
|
"delete it": "verwijderen",
|
||||||
|
"fifth": "vijfde",
|
||||||
|
"first": "eerste",
|
||||||
|
"forward to {address}": "doorsturen naar {address}",
|
||||||
|
"fourth": "vierde",
|
||||||
|
"keep it": "behouden",
|
||||||
|
"last": "laatste",
|
||||||
|
"mark it read": "als gelezen markeren",
|
||||||
|
"move to {folder}": "verplaatsen naar {folder}",
|
||||||
|
"reject it": "weigeren",
|
||||||
|
"remove {flag}": "{flag} verwijderen",
|
||||||
|
"second": "tweede",
|
||||||
|
"size is over {n} KB": "grootte boven {n} KB",
|
||||||
|
"size is under {n} KB": "grootte onder {n} KB",
|
||||||
|
"star it": "een ster geven",
|
||||||
|
"stop": "stoppen",
|
||||||
|
"third": "derde",
|
||||||
|
"{header} address {op} \"{value}\"": "{header}-adres {op} \"{value}\"",
|
||||||
|
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
|
||||||
|
"{rule}, until {date}": "{rule}, tot {date}",
|
||||||
|
"{tests} → {actions}": "{tests} → {actions}",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"This cannot be undone.": "Dit kan niet ongedaan worden gemaakt.",
|
||||||
|
"Some could not be deleted: {error}": "Sommige konden niet worden verwijderd: {error}",
|
||||||
|
"It was not deleted": "Het is niet verwijderd",
|
||||||
|
"Empty address book": "Dit adresboek leegmaken",
|
||||||
|
"There is nothing in it to delete": "Er staat niets in om te verwijderen",
|
||||||
|
"Empty “{name}”?": "„{name}” leegmaken?",
|
||||||
|
"Delete them": "Verwijderen",
|
||||||
|
"Nothing was deleted": "Er is niets verwijderd",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "Certificaat geldt voor",
|
||||||
|
"Details": "Details",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Eerdere berichten van dit adres waren ondertekend door {previous}. Dit bericht is ondertekend door {current}.",
|
||||||
|
"Fingerprint": "Vingerafdruk",
|
||||||
|
"Hide details": "Details verbergen",
|
||||||
|
"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 uses a signature algorithm ihasmail cannot check yet.": "Het gebruikt een ondertekeningsalgoritme dat ihasmail 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.",
|
||||||
|
"Previous fingerprint": "Vorige vingerafdruk",
|
||||||
|
"Signed at": "Ondertekend op",
|
||||||
|
"Signed by {name} — the same signer as before.": "Ondertekend door {name} — dezelfde ondertekenaar als eerder.",
|
||||||
|
"Signed by {name}, seen here for the first time.": "Ondertekend door {name}, hier voor het eerst gezien.",
|
||||||
|
"Signer": "Ondertekenaar",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Dat kan een vernieuwd certificaat betekenen, en het kan iemand anders zijn. Vraag het langs een andere weg na voordat u erop vertrouwt.",
|
||||||
|
"The certificate has expired.": "Het certificaat is verlopen.",
|
||||||
|
"The certificate is not valid yet.": "Het certificaat is nog niet geldig.",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Het bericht komt niet overeen met wat er is ondertekend — het is na ondertekening gewijzigd, of onderweg beschadigd.",
|
||||||
|
"The signature carries no certificate that can be read.": "De handtekening bevat geen leesbaar certificaat.",
|
||||||
|
"The signature could not be read.": "De handtekening kon niet worden gelezen.",
|
||||||
|
"The signature does not match the certificate sent with it.": "De handtekening hoort niet bij het meegestuurde certificaat.",
|
||||||
|
"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 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 signature does not check out.": "Deze handtekening klopt niet.",
|
||||||
|
"Valid until": "Geldig tot",
|
||||||
|
"a different certificate": "een ander certificaat",
|
||||||
|
"an unnamed signer": "een naamloze ondertekenaar",
|
||||||
|
"as claimed by the signer": "volgens de ondertekenaar",
|
||||||
|
"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.",
|
||||||
|
"itself, or an issuer it does not name": "zichzelf, of een uitgever die het niet noemt",
|
||||||
|
"no address": "geen adres",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── 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." },
|
||||||
|
"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." },
|
||||||
|
"{n} domains": { one: "{n} domein", other: "{n} domeinen" },
|
||||||
|
"{n} mailing lists": { one: "{n} mailinglijst", other: "{n} mailinglijsten" },
|
||||||
|
"{n} DKIM keys": { one: "{n} DKIM-sleutel", other: "{n} DKIM-sleutels" },
|
||||||
|
"{n} other items": { one: "{n} ander item", other: "{n} andere items" },
|
||||||
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
"{n} accounts": { one: "{n} account", other: "{n} accounts" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Delete {n} items": { one: "{n} item verwijderen", other: "{n} items verwijderen" },
|
||||||
|
"Delete {n} items?": { one: "{n} item verwijderen?", other: "{n} items verwijderen?" },
|
||||||
|
"Move {n} items": { one: "{n} item verplaatsen", other: "{n} items verplaatsen" },
|
||||||
|
"Move {n} items…": { one: "{n} item verplaatsen…", other: "{n} items verplaatsen…" },
|
||||||
|
"The event runs {n} days longer than this shows.": { one: "De gebeurtenis duurt {n} dag langer dan hier wordt getoond.", other: "De gebeurtenis duurt {n} dagen langer dan hier wordt getoond." },
|
||||||
|
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} gast zit niet op deze server, dus er is geen vrij/bezet voor die persoon te lezen.", other: "{n} gasten zitten niet op deze server, dus er is geen vrij/bezet voor hen te lezen." },
|
||||||
|
"{n} items selected": { one: "{n} item geselecteerd", other: "{n} items geselecteerd" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Every {n} days": { one: "Elke dag", other: "Elke {n} dagen" },
|
||||||
|
"Every {n} months": { one: "Elke maand", other: "Elke {n} maanden" },
|
||||||
|
"Every {n} months on day {days}": { one: "Elke maand op dag {days}", other: "Elke {n} maanden op dag {days}" },
|
||||||
|
"Every {n} months on the {ordinal} {weekday}": { one: "Elke maand op de {ordinal} {weekday}", other: "Elke {n} maanden op de {ordinal} {weekday}" },
|
||||||
|
"Every {n} months on {weekday}": { one: "Elke maand op {weekday}", other: "Elke {n} maanden op {weekday}" },
|
||||||
|
"Every {n} weeks": { one: "Elke week", other: "Elke {n} weken" },
|
||||||
|
"Every {n} weeks on {days}": { one: "Elke week op {days}", other: "Elke {n} weken op {days}" },
|
||||||
|
"Every {n} years": { one: "Elk jaar", other: "Elke {n} jaar" },
|
||||||
|
"{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" },
|
||||||
@@ -1264,8 +1522,7 @@ export const catalog: Catalog = {
|
|||||||
"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" },
|
||||||
"Already here: {n} events, nothing imported": { one: "Al aanwezig: {n} afspraak, niets geïmporteerd", other: "Al aanwezig: {n} afspraken, niets geïmporteerd" },
|
"Updated {n} events, nothing new": { one: "{n} afspraak bijgewerkt, niets nieuws", other: "{n} afspraken bijgewerkt, niets nieuws" },
|
||||||
"{n} were already here": { one: "{n} was er al", other: "{n} waren er al" },
|
|
||||||
"{n} messages": { one: "{n} bericht", other: "{n} berichten" },
|
"{n} messages": { one: "{n} bericht", other: "{n} berichten" },
|
||||||
"{n} selected": { one: "{n} geselecteerd", other: "{n} geselecteerd" },
|
"{n} selected": { one: "{n} geselecteerd", other: "{n} geselecteerd" },
|
||||||
"{n} conversations": { one: "{n} gesprek", other: "{n} gesprekken" },
|
"{n} conversations": { one: "{n} gesprek", other: "{n} gesprekken" },
|
||||||
@@ -1284,5 +1541,10 @@ export const catalog: Catalog = {
|
|||||||
"Marked {n} messages as read": { one: "{n} bericht als gelezen gemarkeerd", other: "{n} berichten als gelezen gemarkeerd" },
|
"Marked {n} messages as read": { one: "{n} bericht als gelezen gemarkeerd", other: "{n} berichten als gelezen gemarkeerd" },
|
||||||
"in {n} folders": { one: "in {n} map", other: "in {n} mappen" },
|
"in {n} folders": { one: "in {n} map", other: "in {n} mappen" },
|
||||||
"Deleted {n} messages": { one: "{n} bericht verwijderd", other: "{n} berichten verwijderd" },
|
"Deleted {n} messages": { one: "{n} bericht verwijderd", other: "{n} berichten verwijderd" },
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"Delete {n} contacts?": { one: "{n} contact verwijderen?", other: "{n} contacten verwijderen?" },
|
||||||
|
"Deleted {n} contacts": { one: "{n} contact verwijderd", other: "{n} contacten verwijderd" },
|
||||||
|
"{n} contacts will be deleted. This cannot be undone.": { one: "{n} contact wordt verwijderd. Dit kan niet ongedaan worden gemaakt.", other: "{n} contacten worden verwijderd. Dit kan niet ongedaan worden gemaakt." },
|
||||||
|
"{n} were also in other address books and were only removed from this one": { one: "{n} contact stond ook in een ander adresboek en is alleen uit dit adresboek verwijderd", other: "{n} contacten stonden ook in andere adresboeken en zijn alleen uit dit adresboek verwijderd" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+275
-13
@@ -50,6 +50,146 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
strings: {
|
||||||
|
// ── Administration: domains ────────────────────────────────────
|
||||||
|
"Domains": "Domínios",
|
||||||
|
"By hand": "Manual",
|
||||||
|
"Signing": "Assinando",
|
||||||
|
"Published, not signing yet": "Publicada, ainda não assina",
|
||||||
|
"Retiring": "Sendo retirada",
|
||||||
|
"Retired": "Retirada",
|
||||||
|
"This domain no longer exists. Someone may have removed it.": "Este domínio não existe mais. Talvez alguém o tenha removido.",
|
||||||
|
"That doesn't look like a domain name, such as example.com.": "Isso não parece um nome de domínio, como example.com.",
|
||||||
|
"Added {name}. Its DNS records are ready to copy.": "{name} adicionado. Os registros DNS estão prontos para copiar.",
|
||||||
|
"Saved {name}": "{name} salvo",
|
||||||
|
"Add domain": "Adicionar domínio",
|
||||||
|
"Added {date}": "Adicionado em {date}",
|
||||||
|
"This domain is disabled on the server.": "Este domínio está desativado no servidor.",
|
||||||
|
"Your role lets you view domains but not change them.": "Sua função permite ver os domínios, mas não alterá-los.",
|
||||||
|
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Domínios novos assinam os e-mails com chaves DKIM que o servidor cria e renova. Os registros DNS aparecem aqui depois que o domínio é adicionado.",
|
||||||
|
"Other names": "Outros nomes",
|
||||||
|
"Delivery": "Entrega",
|
||||||
|
"Catch-all address": "Endereço pega-tudo",
|
||||||
|
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "E-mails para um endereço que ninguém tem neste domínio são entregues aqui. Deixe vazio para recusá-los.",
|
||||||
|
"Plus addressing": "Endereços com +",
|
||||||
|
"Set by a custom rule on the server.": "Definido por uma regra personalizada no servidor.",
|
||||||
|
"Mail to name+anything@ is delivered to name@.": "E-mails para nome+qualquercoisa@ são entregues a nome@.",
|
||||||
|
"DNS records": "Registros DNS",
|
||||||
|
"Published automatically through {provider}.": "Publicados automaticamente via {provider}.",
|
||||||
|
"Published automatically by the server.": "Publicados automaticamente pelo servidor.",
|
||||||
|
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Adicione-os onde o DNS deste domínio está hospedado. Os e-mails não são entregues nem considerados confiáveis até que estejam lá.",
|
||||||
|
"Copy {type} record for {name}": "Copiar o registro {type} de {name}",
|
||||||
|
"Copy value": "Copiar valor",
|
||||||
|
"Copied the zone file": "Arquivo de zona copiado",
|
||||||
|
"Copy all as a zone file": "Copiar tudo como arquivo de zona",
|
||||||
|
"The server returned no records for this domain.": "O servidor não retornou registros para este domínio.",
|
||||||
|
"DKIM keys": "Chaves DKIM",
|
||||||
|
"The server creates and rotates these keys itself.": "O servidor cria e renova estas chaves sozinho.",
|
||||||
|
"These keys are managed by hand on the server.": "Estas chaves são gerenciadas manualmente no servidor.",
|
||||||
|
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "Sem chaves DKIM: os e-mails deste domínio não são assinados e têm mais chance de ir para o spam.",
|
||||||
|
"Managed by the server": "Gerenciado pelo servidor",
|
||||||
|
"Certificate": "Certificado",
|
||||||
|
"Another name for this domain": "Outro nome para este domínio",
|
||||||
|
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "E-mails para o mesmo endereço em qualquer um destes nomes chegam à mesma conta. As alterações valem ao salvar.",
|
||||||
|
"Its DKIM keys have to be removed first, and your role can't remove them.": "As chaves DKIM precisam ser removidas primeiro, e sua função não pode removê-las.",
|
||||||
|
"The server stops accepting mail for this domain.": "O servidor deixa de aceitar e-mails para este domínio.",
|
||||||
|
"Remove domain…": "Remover domínio…",
|
||||||
|
"Remove {name}?": "Remover {name}?",
|
||||||
|
"Removed {name}": "{name} removido",
|
||||||
|
"The server kept the domain: it is still used by {things}.": "O servidor manteve o domínio: ele ainda é usado por {things}.",
|
||||||
|
"Remove domain": "Remover domínio",
|
||||||
|
"The server stops accepting mail for this domain. This can't be undone.": "O servidor deixa de aceitar e-mails para este domínio. Não é possível desfazer.",
|
||||||
|
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Onde seus endereços ficam, e os registros DNS que fazem os e-mails chegarem e serem confiáveis.",
|
||||||
|
"Search domains": "Pesquisar domínios",
|
||||||
|
"No domains match": "Nenhum domínio corresponde",
|
||||||
|
"No domains yet": "Ainda não há domínios",
|
||||||
|
"DKIM": "DKIM",
|
||||||
|
"Tenant": "Locatário",
|
||||||
|
"Disabled": "Desativado",
|
||||||
|
"also {names}": "também {names}",
|
||||||
|
"The server did not say whether the domain was created.": "O servidor não informou se o domínio foi criado.",
|
||||||
|
// ── Administration: refusals ───────────────────────────────────
|
||||||
|
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Esse não é um nome de domínio válido. Use um nome como example.com, com um domínio de nível superior real.",
|
||||||
|
"That isn't a valid email address. Use a full address, such as [email protected].": "Esse não é um endereço de e-mail válido. Use um endereço completo, como [email protected].",
|
||||||
|
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Esse não é um endereço válido. Use letras, números, pontos, hifens ou sublinhados antes do @.",
|
||||||
|
"That isn't a valid host name or IP address.": "Esse não é um nome de host ou endereço IP válido.",
|
||||||
|
"A required value was left empty.": "Um valor obrigatório foi deixado em branco.",
|
||||||
|
"Administration is turned off on this installation.": "A administração está desativada nesta instalação.",
|
||||||
|
"The mail server could not carry out the request ({code}).": "O servidor de e-mail não conseguiu executar a solicitação ({code}).",
|
||||||
|
"You can't give an account permissions your own role doesn't have.": "Você não pode dar a uma conta permissões que sua própria função não tem.",
|
||||||
|
"This account signs in through an external directory, so its password can't be set here.": "Esta conta entra por meio de um diretório externo, então a senha dela não pode ser definida aqui.",
|
||||||
|
"The server's licence allows no more accounts.": "A licença do servidor não permite mais contas.",
|
||||||
|
"That domain name is already in use on this server, as a domain or another domain's other name.": "Esse nome de domínio já está em uso neste servidor, como domínio ou como outro nome de outro domínio.",
|
||||||
|
"Your organisation has reached the number of domains it is allowed.": "Sua organização atingiu o número de domínios permitido.",
|
||||||
|
"That is more than the mail server accepts in one change.": "Isso é mais do que o servidor de e-mail aceita em uma única alteração.",
|
||||||
|
"The mail server rejected one of the values. Check what you entered and try again.": "O servidor de e-mail recusou um dos valores. Confira o que você digitou e tente novamente.",
|
||||||
|
"The mail server refused the change ({code}).": "O servidor de e-mail recusou a alteração ({code}).",
|
||||||
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
|
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Só em um dispositivo que você marcou como seu. Entre novamente com “Este dispositivo é meu” marcado.",
|
||||||
|
"Change your own password in {settings}.": "Altere sua própria senha em {settings}.",
|
||||||
|
"Administration": "Administração",
|
||||||
|
"Directory": "Diretório",
|
||||||
|
"User": "Usuário",
|
||||||
|
"Administrator": "Administrador",
|
||||||
|
"Custom role": "Função personalizada",
|
||||||
|
"New account": "Nova conta",
|
||||||
|
"The people who sign in to mail on the domains you manage.": "As pessoas que entram no e-mail nos domínios que você administra.",
|
||||||
|
"Search by name or address": "Pesquisar por nome ou endereço",
|
||||||
|
"Search accounts": "Pesquisar contas",
|
||||||
|
"No accounts match": "Nenhuma conta corresponde",
|
||||||
|
"No accounts yet": "Ainda não há contas",
|
||||||
|
"Nothing on your domains matches “{query}”.": "Nada nos seus domínios corresponde a “{query}”.",
|
||||||
|
"Open {address}": "Abrir {address}",
|
||||||
|
"{from}–{to} of {total}": "{from}–{to} de {total}",
|
||||||
|
"Previous page": "Página anterior",
|
||||||
|
"Next page": "Próxima página",
|
||||||
|
"Storage": "Armazenamento",
|
||||||
|
"Groups": "Grupos",
|
||||||
|
"{used} · no limit": "{used} · sem limite",
|
||||||
|
"Profile": "Perfil",
|
||||||
|
"Domain": "Domínio",
|
||||||
|
"No domains are available to create an account on.": "Não há nenhum domínio disponível para criar uma conta.",
|
||||||
|
"Sign-in": "Acesso",
|
||||||
|
"Other addresses": "Outros endereços",
|
||||||
|
"Not in any group": "Não está em nenhum grupo",
|
||||||
|
"You can't change your own role.": "Você não pode alterar sua própria função.",
|
||||||
|
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Só são oferecidas as funções cujas permissões você mesmo tem. Em uma conta dentro de um locatário, Administrador significa administrador desse locatário.",
|
||||||
|
"Limit in GB": "Limite em GB",
|
||||||
|
"No limit": "Sem limite",
|
||||||
|
"This account has permissions yours doesn't, so you can view it but not change it.": "Esta conta 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 accounts but not change them.": "Sua função permite ver as contas, mas não alterá-las.",
|
||||||
|
"This account has permissions yours doesn't.": "Esta conta tem permissões que a sua não tem.",
|
||||||
|
"You can't delete the account you're signed in with.": "Você não pode excluir a conta com a qual está conectado.",
|
||||||
|
"Create account": "Criar conta",
|
||||||
|
"An account needs an address.": "Uma conta precisa de um endereço.",
|
||||||
|
"Created {address}": "{address} criada",
|
||||||
|
"Saved {address}": "{address} salva",
|
||||||
|
"Generate a password": "Gerar uma senha",
|
||||||
|
"Pass it on some way other than email to this address.": "Repasse-a por outro meio que não seja um e-mail para este endereço.",
|
||||||
|
"This account has no password. It may sign in through a directory or single sign-on.": "Esta conta não tem senha. Ela pode entrar por meio de um diretório ou de login único.",
|
||||||
|
"Set a new password…": "Definir nova senha…",
|
||||||
|
"{name} will be signed out of every app and device using the old password.": "{name} será desconectado de todos os apps e dispositivos que usam a senha antiga.",
|
||||||
|
"New password set for {address}": "Nova senha definida para {address}",
|
||||||
|
"Set password": "Definir senha",
|
||||||
|
"Remove {address}": "Remover {address}",
|
||||||
|
"New address": "Novo endereço",
|
||||||
|
"another name": "outro nome",
|
||||||
|
"Mail to these addresses is delivered to this account. Changes apply when you save.": "Os e-mails enviados a estes endereços são entregues nesta conta. As alterações valem ao salvar.",
|
||||||
|
"Deletes the mailbox and everything in it.": "Exclui a caixa de correio e tudo o que há nela.",
|
||||||
|
"Delete account…": "Excluir conta…",
|
||||||
|
"Delete {address}?": "Excluir {address}?",
|
||||||
|
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Isto exclui os e-mails, agendas, contatos e arquivos desta conta. O servidor os remove em segundo plano, e não é possível desfazer.",
|
||||||
|
"Type {address} to confirm": "Digite {address} para confirmar",
|
||||||
|
"Delete account": "Excluir conta",
|
||||||
|
"Deleted {address}": "{address} excluída",
|
||||||
|
"The server did not say whether the account was created.": "O servidor não informou se a conta foi criada.",
|
||||||
|
"The mail server refused this. Your role may not allow it.": "O servidor de e-mail recusou. Talvez sua função não permita.",
|
||||||
|
"That address is already in use on this server, as an account, a list or an alias.": "Esse endereço já está em uso neste servidor, como conta, lista ou alias.",
|
||||||
|
"One of the chosen domain, role or group can't be used for this account.": "O domínio, a função ou o grupo escolhido não pode ser usado nesta conta.",
|
||||||
|
"Your organisation has reached the number of accounts it is allowed.": "Sua organização atingiu o número de contas permitido.",
|
||||||
|
"Something still depends on this, so the server kept it.": "Algo ainda depende disto, então o servidor o manteve.",
|
||||||
|
"This account no longer exists. Someone may have deleted it.": "Esta conta não existe mais. Talvez alguém a tenha excluído.",
|
||||||
|
"The password was not accepted: {reason}": "A senha não foi aceita: {reason}",
|
||||||
|
"The password was not accepted.": "A senha não foi aceita.",
|
||||||
"Go to folder…": "Ir para a pasta…",
|
"Go to folder…": "Ir para a pasta…",
|
||||||
"Set for everyone here. You cannot change this.": "Definido para todos aqui. Você não pode alterar isto.",
|
"Set for everyone here. You cannot change this.": "Definido para todos aqui. Você não pode alterar isto.",
|
||||||
"Export iCAL file": "Exportar arquivo iCAL",
|
"Export iCAL file": "Exportar arquivo iCAL",
|
||||||
@@ -322,7 +462,6 @@ export const catalog: Catalog = {
|
|||||||
"Busy": "Ocupado",
|
"Busy": "Ocupado",
|
||||||
"Free/busy": "Disponibilidade",
|
"Free/busy": "Disponibilidade",
|
||||||
"Show as": "Mostrar como",
|
"Show as": "Mostrar como",
|
||||||
"Availability on {date}": "Disponibilidade em {date}",
|
|
||||||
"Count all events as busy": "Contar todos os eventos como ocupado",
|
"Count all events as busy": "Contar todos os eventos como ocupado",
|
||||||
"Only events I'm attending": "Somente os eventos de que participo",
|
"Only events I'm attending": "Somente os eventos de que participo",
|
||||||
"Don't include in availability": "Não incluir na disponibilidade",
|
"Don't include in availability": "Não incluir na disponibilidade",
|
||||||
@@ -361,7 +500,6 @@ export const catalog: Catalog = {
|
|||||||
"New address book": "Novo catálogo de endereços",
|
"New address book": "Novo catálogo de endereços",
|
||||||
"No address books yet.": "Ainda não há catálogos de endereços.",
|
"No address books yet.": "Ainda não há catálogos de endereços.",
|
||||||
"Choose from address books": "Escolher nos catálogos de endereços",
|
"Choose from address books": "Escolher nos catálogos de endereços",
|
||||||
"Import vCard": "Importar um vCard",
|
|
||||||
"Export all contacts": "Exportar todos os contatos",
|
"Export all contacts": "Exportar todos os contatos",
|
||||||
"Export address book": "Exportar este catálogo de endereços",
|
"Export address book": "Exportar este catálogo de endereços",
|
||||||
"Import contacts…": "Importar contatos…",
|
"Import contacts…": "Importar contatos…",
|
||||||
@@ -435,7 +573,6 @@ export const catalog: Catalog = {
|
|||||||
"Make ihasmail yours.": "Deixe o ihasmail do seu jeito.",
|
"Make ihasmail yours.": "Deixe o ihasmail do seu jeito.",
|
||||||
"Reading": "Leitura",
|
"Reading": "Leitura",
|
||||||
"Reading pane": "Painel de leitura",
|
"Reading pane": "Painel de leitura",
|
||||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "Comportamento de leitura, envio e lista. As configurações ficam guardadas neste navegador.",
|
|
||||||
"Right of the list": "À direita da lista",
|
"Right of the list": "À direita da lista",
|
||||||
"Below the list": "Abaixo da lista",
|
"Below the list": "Abaixo da lista",
|
||||||
"Hidden (open full width)": "Oculto (abrir em largura total)",
|
"Hidden (open full width)": "Oculto (abrir em largura total)",
|
||||||
@@ -478,10 +615,6 @@ export const catalog: Catalog = {
|
|||||||
"Time zone": "Fuso horário",
|
"Time zone": "Fuso horário",
|
||||||
"Week starts on": "A semana começa em",
|
"Week starts on": "A semana começa em",
|
||||||
"Monday": "Segunda-feira",
|
"Monday": "Segunda-feira",
|
||||||
"Tuesday": "Terça-feira",
|
|
||||||
"Wednesday": "Quarta-feira",
|
|
||||||
"Thursday": "Quinta-feira",
|
|
||||||
"Friday": "Sexta-feira",
|
|
||||||
"Saturday": "Sábado",
|
"Saturday": "Sábado",
|
||||||
"Sunday": "Domingo",
|
"Sunday": "Domingo",
|
||||||
"12-hour clock (6:23 PM)": "Formato de 12 horas (6:23 PM)",
|
"12-hour clock (6:23 PM)": "Formato de 12 horas (6:23 PM)",
|
||||||
@@ -521,6 +654,8 @@ export const catalog: Catalog = {
|
|||||||
"Show labels in the sidebar": "Mostrar os marcadores na barra lateral",
|
"Show labels in the sidebar": "Mostrar os marcadores na barra lateral",
|
||||||
"Collapse sidebar to icons": "Recolher a barra lateral em ícones",
|
"Collapse sidebar to icons": "Recolher a barra lateral em ícones",
|
||||||
"Apply the theme to messages too": "Aplicar o tema também às mensagens",
|
"Apply the theme to messages too": "Aplicar o tema também às mensagens",
|
||||||
|
"Apply it even to mail that styles itself": "Aplicar mesmo em mensagens com estilo próprio",
|
||||||
|
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Quase toda mensagem de marketing ou de recibo define alguma cor, então a opção acima deixa quase todas em um cartão branco. Com isto ativado, o tema é imposto sobre as cores do remetente: os fundos sobre os quais a mensagem foi montada são descartados, enquanto botões e faixas coloridas são preservados para que o texto continue legível. Algumas mensagens não sobrevivem intactas, e por isso esta é uma opção separada.",
|
||||||
"Swiping": "Gestos de deslizar",
|
"Swiping": "Gestos de deslizar",
|
||||||
"Swipe left": "Deslizar para a esquerda",
|
"Swipe left": "Deslizar para a esquerda",
|
||||||
"Swipe right": "Deslizar para a direita",
|
"Swipe right": "Deslizar para a direita",
|
||||||
@@ -726,10 +861,8 @@ export const catalog: Catalog = {
|
|||||||
"Manage labels": "Gerenciar os marcadores",
|
"Manage labels": "Gerenciar os marcadores",
|
||||||
"Create “{name}”": "Criar “{name}”",
|
"Create “{name}”": "Criar “{name}”",
|
||||||
"Type a name to create your first label.": "Digite um nome para criar seu primeiro marcador.",
|
"Type a name to create your first label.": "Digite um nome para criar seu primeiro marcador.",
|
||||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Os marcadores são palavras-chave IMAP guardadas nas suas mensagens, então eles sincronizam com outros clientes. Os nomes e as cores ficam neste navegador.",
|
|
||||||
"New label": "Novo marcador",
|
"New label": "Novo marcador",
|
||||||
"Delete label": "Excluir o marcador",
|
"Delete label": "Excluir o marcador",
|
||||||
"PDF": "PDF",
|
|
||||||
"Large attachments may be rejected by some servers": "Anexos grandes podem ser recusados por alguns servidores",
|
"Large attachments may be rejected by some servers": "Anexos grandes podem ser recusados por alguns servidores",
|
||||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "As imagens são guardadas nos seus Arquivos (pasta “ihasmail”) e incorporadas no envio.",
|
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "As imagens são guardadas nos seus Arquivos (pasta “ihasmail”) e incorporadas no envio.",
|
||||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Obrigado pela sua mensagem. Estarei ausente até … e responderei quando voltar.",
|
"Thanks for your message. I'm away until … and will reply when I'm back.": "Obrigado pela sua mensagem. Estarei ausente até … e responderei quando voltar.",
|
||||||
@@ -802,6 +935,8 @@ export const catalog: Catalog = {
|
|||||||
"folder\u0004Junk Mail": "Spam",
|
"folder\u0004Junk Mail": "Spam",
|
||||||
"folder\u0004Important": "Importante",
|
"folder\u0004Important": "Importante",
|
||||||
"folder\u0004All mail": "Todas as mensagens",
|
"folder\u0004All mail": "Todas as mensagens",
|
||||||
|
"share sheet\u0004Share": "Compartilhar",
|
||||||
|
"share sheet\u0004Share…": "Compartilhar…",
|
||||||
"folder": "pasta",
|
"folder": "pasta",
|
||||||
"“{name}” moved into “{parent}”": "“{name}” foi movida para “{parent}”",
|
"“{name}” moved into “{parent}”": "“{name}” foi movida para “{parent}”",
|
||||||
"“{name}” moved to the top level": "“{name}” foi movida para o nível superior",
|
"“{name}” moved to the top level": "“{name}” foi movida para o nível superior",
|
||||||
@@ -810,6 +945,7 @@ export const catalog: Catalog = {
|
|||||||
"Rename folder": "Renomear a pasta",
|
"Rename folder": "Renomear a pasta",
|
||||||
"Search: {query}": "Pesquisa: {query}",
|
"Search: {query}": "Pesquisa: {query}",
|
||||||
"No conversation selected": "Nenhuma conversa selecionada",
|
"No conversation selected": "Nenhuma conversa selecionada",
|
||||||
|
"No message selected": "Nenhuma mensagem selecionada",
|
||||||
"Drop here for the top level": "Solte aqui para o nível superior",
|
"Drop here for the top level": "Solte aqui para o nível superior",
|
||||||
|
|
||||||
// ── Longer prose ───────────────────────────────────────────────────
|
// ── Longer prose ───────────────────────────────────────────────────
|
||||||
@@ -818,6 +954,7 @@ export const catalog: Catalog = {
|
|||||||
"Open the Mail view to see all shortcuts.": "Abra a visualização de E-mail para ver todos os atalhos.",
|
"Open the Mail view to see all shortcuts.": "Abra a visualização de E-mail para ver todos os atalhos.",
|
||||||
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Os atalhos no estilo do Gmail estão sempre ativos. Pressione {key} em qualquer lugar para ver esta lista.",
|
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Os atalhos no estilo do Gmail estão sempre ativos. Pressione {key} em qualquer lugar para ver esta lista.",
|
||||||
"Select a conversation to read it here · Press {key} for shortcuts": "Selecione uma conversa para lê-la aqui · {key} para os atalhos",
|
"Select a conversation to read it here · Press {key} for shortcuts": "Selecione uma conversa para lê-la aqui · {key} para os atalhos",
|
||||||
|
"Select a message to read it here · Press {key} for shortcuts": "Selecione uma mensagem para lê-la aqui · {key} para os atalhos",
|
||||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Dica: pressione {key} em uma conversa para aplicar marcadores. Pesquise com {operator}.",
|
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Dica: pressione {key} em uma conversa para aplicar marcadores. Pesquise com {operator}.",
|
||||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Um webmail livre, rápido e agradável para {server}, feito sobre JMAP.",
|
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Um webmail livre, rápido e agradável para {server}, feito sobre JMAP.",
|
||||||
"Defaults for the calendar views and new events.": "Padrões das visualizações da agenda e dos eventos novos.",
|
"Defaults for the calendar views and new events.": "Padrões das visualizações da agenda e dos eventos novos.",
|
||||||
@@ -859,11 +996,12 @@ export const catalog: Catalog = {
|
|||||||
"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 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.",
|
||||||
"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}.",
|
||||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} é a paleta de {site}, e com a qual uma conta nova começa. É um tema escuro, então conta como escuro onde isso importa, e a cor de destaque abaixo continua valendo por cima dele.",
|
|
||||||
"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.",
|
"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.",
|
||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Nova mensagem",
|
"New message": "Nova mensagem",
|
||||||
|
"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",
|
||||||
"Sending…": "Enviando…",
|
"Sending…": "Enviando…",
|
||||||
"Saving…": "Salvando…",
|
"Saving…": "Salvando…",
|
||||||
"Error": "Erro",
|
"Error": "Erro",
|
||||||
@@ -904,6 +1042,7 @@ export const catalog: Catalog = {
|
|||||||
"Could not copy the address": "Não foi possível copiar o endereço",
|
"Could not copy the address": "Não foi possível copiar o endereço",
|
||||||
"Could not empty folder: {error}": "Não foi possível esvaziar a pasta: {error}",
|
"Could not empty folder: {error}": "Não foi possível esvaziar a pasta: {error}",
|
||||||
"Could not load source: {error}": "Não foi possível carregar o código-fonte: {error}",
|
"Could not load source: {error}": "Não foi possível carregar o código-fonte: {error}",
|
||||||
|
"Could not share: {error}": "Não foi possível compartilhar: {error}",
|
||||||
"Could not mark as read: {error}": "Não foi possível marcar como lida: {error}",
|
"Could not mark as read: {error}": "Não foi possível marcar como lida: {error}",
|
||||||
"Could not save draft: {error}": "Não foi possível salvar o rascunho: {error}",
|
"Could not save draft: {error}": "Não foi possível salvar o rascunho: {error}",
|
||||||
"Could not save filter: {error}": "Não foi possível salvar o filtro: {error}",
|
"Could not save filter: {error}": "Não foi possível salvar o filtro: {error}",
|
||||||
@@ -1112,7 +1251,7 @@ export const catalog: Catalog = {
|
|||||||
"Date received": "Data de recebimento",
|
"Date received": "Data de recebimento",
|
||||||
"Date sent": "Data de envio",
|
"Date sent": "Data de envio",
|
||||||
"Day view": "Visualização de dia",
|
"Day view": "Visualização de dia",
|
||||||
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula, Gruvbox, Rosé Pine e Tokyo Night são obra dos seus próprios projetos e são usados sob a licença MIT; os tons entre as cores publicadas por eles são derivados, e cada um deles é verificado quanto ao contraste. A cor de destaque abaixo continua se aplicando sobre qualquer um deles.",
|
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "As paletas que levam o nome de outro projeto são obra desse projeto e são usadas sob a licença dele; os tons entre as cores publicadas são derivados, e cada um é verificado quanto ao contraste. A cor de destaque abaixo continua se aplicando sobre qualquer uma delas.",
|
||||||
"Earlier": "Antes",
|
"Earlier": "Antes",
|
||||||
"Every folder": "Todas as pastas",
|
"Every folder": "Todas as pastas",
|
||||||
"Everyone addressed will receive this.": "Todos os destinatários receberão isto.",
|
"Everyone addressed will receive this.": "Todos os destinatários receberão isto.",
|
||||||
@@ -1213,6 +1352,14 @@ export const catalog: Catalog = {
|
|||||||
"Throw away your changes?": "Descartar suas alterações?",
|
"Throw away your changes?": "Descartar suas alterações?",
|
||||||
"Today, in your date format": "Hoje, no seu formato de data",
|
"Today, in your date format": "Hoje, no seu formato de data",
|
||||||
"Unread first": "Não lidas primeiro",
|
"Unread first": "Não lidas primeiro",
|
||||||
|
"Read first": "Lidas primeiro",
|
||||||
|
"Unstarred first": "Não favoritas primeiro",
|
||||||
|
"Smallest first": "Menores primeiro",
|
||||||
|
"Z to A": "De Z a A",
|
||||||
|
"A to Z": "De A a Z",
|
||||||
|
"It reads {shown} but goes to {actual}.": "Aparece como {shown}, mas leva para {actual}.",
|
||||||
|
"The full address is {href}.": "O endereço completo é {href}.",
|
||||||
|
"This message came from {domain}, which is outside your organisation.": "Esta mensagem veio de {domain}, que está fora da sua organização.",
|
||||||
"Unsaved changes": "Alterações não salvas",
|
"Unsaved changes": "Alterações não salvas",
|
||||||
"View as": "Exibir como",
|
"View as": "Exibir como",
|
||||||
"Warnings": "Avisos",
|
"Warnings": "Avisos",
|
||||||
@@ -1258,8 +1405,119 @@ export const catalog: Catalog = {
|
|||||||
"This message was sent automatically, so no read receipt is offered.": "Esta mensagem foi enviada automaticamente, então não há confirmação de leitura a oferecer.",
|
"This message was sent automatically, so no read receipt is offered.": "Esta mensagem foi enviada automaticamente, então não há confirmação de leitura a oferecer.",
|
||||||
"This server will not hold a message longer than {span}.": "Este servidor não retém uma mensagem por mais de {span}.",
|
"This server will not hold a message longer than {span}.": "Este servidor não retém uma mensagem por mais de {span}.",
|
||||||
"Upload failed": "Falha no envio",
|
"Upload failed": "Falha no envio",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
"Every {n} {frequency}": "A cada {n} {frequency}",
|
||||||
|
"Monthly": "Mensalmente",
|
||||||
|
"Monthly on day {days}": "Mensalmente no dia {days}",
|
||||||
|
"Monthly on the {ordinal} {weekday}": "Mensalmente na {ordinal} {weekday}",
|
||||||
|
"Monthly on {weekday}": "Mensalmente em {weekday}",
|
||||||
|
"Weekly": "Semanalmente",
|
||||||
|
"Weekly on {days}": "Semanalmente em {days}",
|
||||||
|
"add {flag}": "adicionar {flag}",
|
||||||
|
"always": "sempre",
|
||||||
|
"body contains \"{value}\"": "o corpo contém \"{value}\"",
|
||||||
|
"body does not contain \"{value}\"": "o corpo não contém \"{value}\"",
|
||||||
|
"delete it": "excluir",
|
||||||
|
"fifth": "quinta",
|
||||||
|
"first": "primeira",
|
||||||
|
"forward to {address}": "encaminhar para {address}",
|
||||||
|
"fourth": "quarta",
|
||||||
|
"keep it": "manter",
|
||||||
|
"last": "última",
|
||||||
|
"mark it read": "marcar como lida",
|
||||||
|
"move to {folder}": "mover para {folder}",
|
||||||
|
"reject it": "rejeitar",
|
||||||
|
"remove {flag}": "remover {flag}",
|
||||||
|
"second": "segunda",
|
||||||
|
"size is over {n} KB": "o tamanho passa de {n} KB",
|
||||||
|
"size is under {n} KB": "o tamanho é menor que {n} KB",
|
||||||
|
"star it": "favoritar",
|
||||||
|
"stop": "parar",
|
||||||
|
"third": "terceira",
|
||||||
|
"{header} address {op} \"{value}\"": "o endereço de {header} {op} \"{value}\"",
|
||||||
|
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
|
||||||
|
"{rule}, until {date}": "{rule}, até {date}",
|
||||||
|
"{tests} → {actions}": "{tests} → {actions}",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"This cannot be undone.": "Isso não pode ser desfeito.",
|
||||||
|
"Some could not be deleted: {error}": "Alguns não puderam ser excluídos: {error}",
|
||||||
|
"It was not deleted": "Não foi excluído",
|
||||||
|
"Empty address book": "Esvaziar este catálogo de endereços",
|
||||||
|
"There is nothing in it to delete": "Não há nada nele para excluir",
|
||||||
|
"Empty “{name}”?": "Esvaziar “{name}”?",
|
||||||
|
"Delete them": "Excluir todos",
|
||||||
|
"Nothing was deleted": "Nada foi excluído",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "O certificado cobre",
|
||||||
|
"Details": "Detalhes",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "As mensagens anteriores deste endereço eram assinadas por {previous}. Esta é assinada por {current}.",
|
||||||
|
"Fingerprint": "Impressão digital",
|
||||||
|
"Hide details": "Ocultar detalhes",
|
||||||
|
"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 uses a signature algorithm ihasmail cannot check yet.": "Usa um algoritmo de assinatura que o ihasmail 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.",
|
||||||
|
"Previous fingerprint": "Impressão digital anterior",
|
||||||
|
"Signed at": "Assinado em",
|
||||||
|
"Signed by {name} — the same signer as before.": "Assinado por {name} — o mesmo signatário de antes.",
|
||||||
|
"Signed by {name}, seen here for the first time.": "Assinado por {name}, visto aqui pela primeira vez.",
|
||||||
|
"Signer": "Signatário",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Isso pode ser um certificado renovado, e pode ser outra pessoa. Confirme com ela por outro caminho antes de confiar.",
|
||||||
|
"The certificate has expired.": "O certificado expirou.",
|
||||||
|
"The certificate is not valid yet.": "O certificado ainda não é válido.",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "A mensagem não corresponde ao que foi assinado — ela foi alterada depois da assinatura, ou danificada no caminho.",
|
||||||
|
"The signature carries no certificate that can be read.": "A assinatura não traz nenhum certificado que possa ser lido.",
|
||||||
|
"The signature could not be read.": "Não foi possível ler a assinatura.",
|
||||||
|
"The signature does not match the certificate sent with it.": "A assinatura não corresponde ao certificado enviado com ela.",
|
||||||
|
"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 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 signature does not check out.": "Esta assinatura não confere.",
|
||||||
|
"Valid until": "Válido até",
|
||||||
|
"a different certificate": "um certificado diferente",
|
||||||
|
"an unnamed signer": "um signatário sem nome",
|
||||||
|
"as claimed by the signer": "conforme declarado pelo signatário",
|
||||||
|
"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.",
|
||||||
|
"itself, or an issuer it does not name": "ele mesmo, ou um emissor que ele não nomeia",
|
||||||
|
"no address": "nenhum endereço",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── 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." },
|
||||||
|
"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." },
|
||||||
|
"{n} domains": { one: "{n} domínio", other: "{n} domínios" },
|
||||||
|
"{n} mailing lists": { one: "{n} lista de e-mails", other: "{n} listas de e-mails" },
|
||||||
|
"{n} DKIM keys": { one: "{n} chave DKIM", other: "{n} chaves DKIM" },
|
||||||
|
"{n} other items": { one: "{n} outro item", other: "{n} outros itens" },
|
||||||
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
"{n} accounts": { one: "{n} conta", other: "{n} contas" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Delete {n} items": { one: "Excluir {n} item", other: "Excluir {n} itens" },
|
||||||
|
"Delete {n} items?": { one: "Excluir {n} item?", other: "Excluir {n} itens?" },
|
||||||
|
"Move {n} items": { one: "Mover {n} item", other: "Mover {n} itens" },
|
||||||
|
"Move {n} items…": { one: "Mover {n} item…", other: "Mover {n} itens…" },
|
||||||
|
"The event runs {n} days longer than this shows.": { one: "O evento dura {n} dia a mais do que é exibido aqui.", other: "O evento dura {n} dias a mais do que é exibido aqui." },
|
||||||
|
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} convidado não está neste servidor, então não há livre/ocupado a consultar para ele.", other: "{n} convidados não estão neste servidor, então não há livre/ocupado a consultar para eles." },
|
||||||
|
"{n} items selected": { one: "{n} item selecionado", other: "{n} itens selecionados" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Every {n} days": { one: "Todo dia", other: "A cada {n} dias" },
|
||||||
|
"Every {n} months": { one: "Todo mês", other: "A cada {n} meses" },
|
||||||
|
"Every {n} months on day {days}": { one: "Todo mês no dia {days}", other: "A cada {n} meses no dia {days}" },
|
||||||
|
"Every {n} months on the {ordinal} {weekday}": { one: "Todo mês na {ordinal} {weekday}", other: "A cada {n} meses na {ordinal} {weekday}" },
|
||||||
|
"Every {n} months on {weekday}": { one: "Todo mês em {weekday}", other: "A cada {n} meses em {weekday}" },
|
||||||
|
"Every {n} weeks": { one: "Toda semana", other: "A cada {n} semanas" },
|
||||||
|
"Every {n} weeks on {days}": { one: "Toda semana em {days}", other: "A cada {n} semanas em {days}" },
|
||||||
|
"Every {n} years": { one: "Todo ano", other: "A cada {n} anos" },
|
||||||
|
"{rule}, {n} times": { one: "{rule}, {n} vez", other: "{rule}, {n} vezes" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
"Move {n} messages to Trash?": { one: "Mover {n} mensagem para a Lixeira?", other: "Mover {n} mensagens para a Lixeira?" },
|
"Move {n} messages to Trash?": { one: "Mover {n} mensagem para a Lixeira?", other: "Mover {n} mensagens para a Lixeira?" },
|
||||||
"{n} days": { one: "{n} dia", other: "{n} dias" },
|
"{n} days": { one: "{n} dia", other: "{n} dias" },
|
||||||
@@ -1271,8 +1529,7 @@ export const catalog: Catalog = {
|
|||||||
"Your administrator changed {n} settings": { one: "Sua administração alterou {n} configuração", other: "Sua administração alterou {n} configurações" },
|
"Your administrator changed {n} settings": { one: "Sua administração alterou {n} configuração", other: "Sua administração alterou {n} configurações" },
|
||||||
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
|
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
|
||||||
"Imported {n} events": { one: "{n} evento importado", other: "{n} eventos importados" },
|
"Imported {n} events": { one: "{n} evento importado", other: "{n} eventos importados" },
|
||||||
"Already here: {n} events, nothing imported": { one: "Já estava aqui: {n} evento, nada importado", other: "Já estavam aqui: {n} eventos, nada importado" },
|
"Updated {n} events, nothing new": { one: "{n} evento atualizado, nada novo", other: "{n} eventos atualizados, nada novo" },
|
||||||
"{n} were already here": { one: "{n} já estava aqui", other: "{n} já estavam aqui" },
|
|
||||||
"{n} messages": { one: "{n} mensagem", other: "{n} mensagens" },
|
"{n} messages": { one: "{n} mensagem", other: "{n} mensagens" },
|
||||||
"{n} selected": { one: "{n} selecionada", other: "{n} selecionadas" },
|
"{n} selected": { one: "{n} selecionada", other: "{n} selecionadas" },
|
||||||
"{n} conversations": { one: "{n} conversa", other: "{n} conversas" },
|
"{n} conversations": { one: "{n} conversa", other: "{n} conversas" },
|
||||||
@@ -1291,5 +1548,10 @@ export const catalog: Catalog = {
|
|||||||
"Marked {n} messages as read": { one: "{n} mensagem marcada como lida", other: "{n} mensagens marcadas como lidas" },
|
"Marked {n} messages as read": { one: "{n} mensagem marcada como lida", other: "{n} mensagens marcadas como lidas" },
|
||||||
"in {n} folders": { one: "em {n} pasta", other: "em {n} pastas" },
|
"in {n} folders": { one: "em {n} pasta", other: "em {n} pastas" },
|
||||||
"Deleted {n} messages": { one: "{n} mensagem excluída", other: "{n} mensagens excluídas" },
|
"Deleted {n} messages": { one: "{n} mensagem excluída", other: "{n} mensagens excluídas" },
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"Delete {n} contacts?": { one: "Excluir {n} contato?", other: "Excluir {n} contatos?" },
|
||||||
|
"Deleted {n} contacts": { one: "{n} contato excluído", other: "{n} contatos excluídos" },
|
||||||
|
"{n} contacts will be deleted. This cannot be undone.": { one: "{n} contato será excluído. Isso não pode ser desfeito.", other: "{n} contatos serão excluídos. Isso não pode ser desfeito." },
|
||||||
|
"{n} were also in other address books and were only removed from this one": { one: "{n} contato também estava em outro catálogo de endereços e foi removido apenas deste", other: "{n} contatos também estavam em outros catálogos de endereços e foram removidos apenas deste" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+275
-13
@@ -49,6 +49,146 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
strings: {
|
||||||
|
// ── Administration: domains ────────────────────────────────────
|
||||||
|
"Domains": "Домены",
|
||||||
|
"By hand": "Вручную",
|
||||||
|
"Signing": "Подписывает",
|
||||||
|
"Published, not signing yet": "Опубликован, ещё не подписывает",
|
||||||
|
"Retiring": "Выводится из работы",
|
||||||
|
"Retired": "Выведен из работы",
|
||||||
|
"This domain no longer exists. Someone may have removed it.": "Этого домена больше нет. Возможно, его кто-то удалил.",
|
||||||
|
"That doesn't look like a domain name, such as example.com.": "Это не похоже на имя домена вроде example.com.",
|
||||||
|
"Added {name}. Its DNS records are ready to copy.": "Домен {name} добавлен. Его DNS-записи готовы к копированию.",
|
||||||
|
"Saved {name}": "Домен {name} сохранён",
|
||||||
|
"Add domain": "Добавить домен",
|
||||||
|
"Added {date}": "Добавлен {date}",
|
||||||
|
"This domain is disabled on the server.": "Этот домен отключён на сервере.",
|
||||||
|
"Your role lets you view domains but not change them.": "Ваша роль позволяет просматривать домены, но не изменять их.",
|
||||||
|
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Новые домены подписывают почту ключами DKIM, которые сервер создаёт и меняет сам. DNS-записи появятся здесь после добавления домена.",
|
||||||
|
"Other names": "Другие имена",
|
||||||
|
"Delivery": "Доставка",
|
||||||
|
"Catch-all address": "Адрес для всей почты",
|
||||||
|
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "Сюда доставляются письма на адреса этого домена, которых ни у кого нет. Оставьте пустым, чтобы отклонять такие письма.",
|
||||||
|
"Plus addressing": "Адреса с «+»",
|
||||||
|
"Set by a custom rule on the server.": "Задано особым правилом на сервере.",
|
||||||
|
"Mail to name+anything@ is delivered to name@.": "Письма на имя+что-угодно@ доставляются на имя@.",
|
||||||
|
"DNS records": "DNS-записи",
|
||||||
|
"Published automatically through {provider}.": "Публикуются автоматически через {provider}.",
|
||||||
|
"Published automatically by the server.": "Публикуются сервером автоматически.",
|
||||||
|
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Добавьте их там, где размещён DNS этого домена. Пока их нет, почта не доставляется и не считается надёжной.",
|
||||||
|
"Copy {type} record for {name}": "Скопировать запись {type} для {name}",
|
||||||
|
"Copy value": "Скопировать значение",
|
||||||
|
"Copied the zone file": "Файл зоны скопирован",
|
||||||
|
"Copy all as a zone file": "Скопировать всё как файл зоны",
|
||||||
|
"The server returned no records for this domain.": "Сервер не вернул записей для этого домена.",
|
||||||
|
"DKIM keys": "Ключи DKIM",
|
||||||
|
"The server creates and rotates these keys itself.": "Сервер создаёт и меняет эти ключи сам.",
|
||||||
|
"These keys are managed by hand on the server.": "Эти ключи управляются на сервере вручную.",
|
||||||
|
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "Ключей DKIM нет, поэтому почта с этого домена не подписывается и чаще попадает в спам.",
|
||||||
|
"Managed by the server": "Управляется сервером",
|
||||||
|
"Certificate": "Сертификат",
|
||||||
|
"Another name for this domain": "Другое имя для этого домена",
|
||||||
|
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "Письма на тот же адрес под любым из этих имён попадают в ту же учётную запись. Изменения вступят в силу после сохранения.",
|
||||||
|
"Its DKIM keys have to be removed first, and your role can't remove them.": "Сначала нужно удалить его ключи DKIM, а ваша роль этого не позволяет.",
|
||||||
|
"The server stops accepting mail for this domain.": "Сервер перестанет принимать почту для этого домена.",
|
||||||
|
"Remove domain…": "Удалить домен…",
|
||||||
|
"Remove {name}?": "Удалить {name}?",
|
||||||
|
"Removed {name}": "Домен {name} удалён",
|
||||||
|
"The server kept the domain: it is still used by {things}.": "Сервер сохранил домен: его ещё используют {things}.",
|
||||||
|
"Remove domain": "Удалить домен",
|
||||||
|
"The server stops accepting mail for this domain. This can't be undone.": "Сервер перестанет принимать почту для этого домена. Отменить это нельзя.",
|
||||||
|
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Где находятся ваши адреса и DNS-записи, благодаря которым почта доходит и вызывает доверие.",
|
||||||
|
"Search domains": "Поиск доменов",
|
||||||
|
"No domains match": "Нет подходящих доменов",
|
||||||
|
"No domains yet": "Доменов пока нет",
|
||||||
|
"DKIM": "DKIM",
|
||||||
|
"Tenant": "Арендатор",
|
||||||
|
"Disabled": "Отключён",
|
||||||
|
"also {names}": "также {names}",
|
||||||
|
"The server did not say whether the domain was created.": "Сервер не сообщил, создан ли домен.",
|
||||||
|
// ── Administration: refusals ───────────────────────────────────
|
||||||
|
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Это недопустимое имя домена. Укажите имя вроде example.com с настоящим доменом верхнего уровня.",
|
||||||
|
"That isn't a valid email address. Use a full address, such as [email protected].": "Это недопустимый адрес электронной почты. Укажите полный адрес, например [email protected].",
|
||||||
|
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Это недопустимый адрес. Перед @ используйте буквы, цифры, точки, дефисы или подчёркивания.",
|
||||||
|
"That isn't a valid host name or IP address.": "Это недопустимое имя хоста или IP-адрес.",
|
||||||
|
"A required value was left empty.": "Обязательное значение не заполнено.",
|
||||||
|
"Administration is turned off on this installation.": "Администрирование отключено в этой установке.",
|
||||||
|
"The mail server could not carry out the request ({code}).": "Почтовый сервер не смог выполнить запрос ({code}).",
|
||||||
|
"You can't give an account permissions your own role doesn't have.": "Нельзя дать учётной записи разрешения, которых нет у вашей роли.",
|
||||||
|
"This account signs in through an external directory, so its password can't be set here.": "Эта учётная запись входит через внешний каталог, поэтому её пароль нельзя задать здесь.",
|
||||||
|
"The server's licence allows no more accounts.": "Лицензия сервера не допускает новых учётных записей.",
|
||||||
|
"That domain name is already in use on this server, as a domain or another domain's other name.": "Это имя домена уже используется на сервере — как домен или как другое имя другого домена.",
|
||||||
|
"Your organisation has reached the number of domains it is allowed.": "Ваша организация достигла допустимого числа доменов.",
|
||||||
|
"That is more than the mail server accepts in one change.": "Это больше, чем почтовый сервер принимает за одно изменение.",
|
||||||
|
"The mail server rejected one of the values. Check what you entered and try again.": "Почтовый сервер отклонил одно из значений. Проверьте введённые данные и попробуйте снова.",
|
||||||
|
"The mail server refused the change ({code}).": "Почтовый сервер отклонил изменение ({code}).",
|
||||||
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
|
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Только на устройстве, отмеченном как ваше. Войдите снова, отметив «Это моё личное устройство».",
|
||||||
|
"Change your own password in {settings}.": "Свой пароль можно изменить в разделе {settings}.",
|
||||||
|
"Administration": "Администрирование",
|
||||||
|
"Directory": "Каталог",
|
||||||
|
"User": "Пользователь",
|
||||||
|
"Administrator": "Администратор",
|
||||||
|
"Custom role": "Особая роль",
|
||||||
|
"New account": "Новая учётная запись",
|
||||||
|
"The people who sign in to mail on the domains you manage.": "Люди, которые входят в почту на доменах, которыми вы управляете.",
|
||||||
|
"Search by name or address": "Поиск по имени или адресу",
|
||||||
|
"Search accounts": "Поиск учётных записей",
|
||||||
|
"No accounts match": "Нет подходящих учётных записей",
|
||||||
|
"No accounts yet": "Учётных записей пока нет",
|
||||||
|
"Nothing on your domains matches “{query}”.": "На ваших доменах нет совпадений с «{query}».",
|
||||||
|
"Open {address}": "Открыть {address}",
|
||||||
|
"{from}–{to} of {total}": "{from}–{to} из {total}",
|
||||||
|
"Previous page": "Предыдущая страница",
|
||||||
|
"Next page": "Следующая страница",
|
||||||
|
"Storage": "Хранилище",
|
||||||
|
"Groups": "Группы",
|
||||||
|
"{used} · no limit": "{used} · без ограничения",
|
||||||
|
"Profile": "Профиль",
|
||||||
|
"Domain": "Домен",
|
||||||
|
"No domains are available to create an account on.": "Нет доменов, на которых можно создать учётную запись.",
|
||||||
|
"Sign-in": "Вход",
|
||||||
|
"Other addresses": "Другие адреса",
|
||||||
|
"Not in any group": "Не состоит ни в одной группе",
|
||||||
|
"You can't change your own role.": "Нельзя изменить собственную роль.",
|
||||||
|
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Предлагаются только роли, все разрешения которых есть у вас самих. Для учётной записи внутри арендатора «Администратор» означает администратора этого арендатора.",
|
||||||
|
"Limit in GB": "Ограничение в ГБ",
|
||||||
|
"No limit": "Без ограничения",
|
||||||
|
"This account has permissions yours doesn't, so you can view it but not change it.": "У этой учётной записи есть разрешения, которых нет у вашей, поэтому её можно просматривать, но не изменять.",
|
||||||
|
"Your role lets you view accounts but not change them.": "Ваша роль позволяет просматривать учётные записи, но не изменять их.",
|
||||||
|
"This account has permissions yours doesn't.": "У этой учётной записи есть разрешения, которых нет у вашей.",
|
||||||
|
"You can't delete the account you're signed in with.": "Нельзя удалить учётную запись, под которой вы вошли.",
|
||||||
|
"Create account": "Создать учётную запись",
|
||||||
|
"An account needs an address.": "Учётной записи нужен адрес.",
|
||||||
|
"Created {address}": "Учётная запись {address} создана",
|
||||||
|
"Saved {address}": "Учётная запись {address} сохранена",
|
||||||
|
"Generate a password": "Сгенерировать пароль",
|
||||||
|
"Pass it on some way other than email to this address.": "Передайте его любым способом, кроме письма на этот адрес.",
|
||||||
|
"This account has no password. It may sign in through a directory or single sign-on.": "У этой учётной записи нет пароля. Возможно, вход выполняется через каталог или единый вход.",
|
||||||
|
"Set a new password…": "Задать новый пароль…",
|
||||||
|
"{name} will be signed out of every app and device using the old password.": "Для {name} будет выполнен выход во всех приложениях и на всех устройствах, где используется старый пароль.",
|
||||||
|
"New password set for {address}": "Новый пароль для {address} задан",
|
||||||
|
"Set password": "Задать пароль",
|
||||||
|
"Remove {address}": "Удалить {address}",
|
||||||
|
"New address": "Новый адрес",
|
||||||
|
"another name": "другое имя",
|
||||||
|
"Mail to these addresses is delivered to this account. Changes apply when you save.": "Письма на эти адреса доставляются в эту учётную запись. Изменения вступят в силу после сохранения.",
|
||||||
|
"Deletes the mailbox and everything in it.": "Удаляет почтовый ящик и всё его содержимое.",
|
||||||
|
"Delete account…": "Удалить учётную запись…",
|
||||||
|
"Delete {address}?": "Удалить {address}?",
|
||||||
|
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Будут удалены почта, календари, контакты и файлы этой учётной записи. Сервер удалит их в фоновом режиме, отменить это нельзя.",
|
||||||
|
"Type {address} to confirm": "Введите {address} для подтверждения",
|
||||||
|
"Delete account": "Удалить учётную запись",
|
||||||
|
"Deleted {address}": "Учётная запись {address} удалена",
|
||||||
|
"The server did not say whether the account was created.": "Сервер не сообщил, создана ли учётная запись.",
|
||||||
|
"The mail server refused this. Your role may not allow it.": "Почтовый сервер отклонил это действие. Возможно, ваша роль его не допускает.",
|
||||||
|
"That address is already in use on this server, as an account, a list or an alias.": "Этот адрес уже используется на сервере — учётной записью, списком или псевдонимом.",
|
||||||
|
"One of the chosen domain, role or group can't be used for this account.": "Выбранный домен, роль или группу нельзя использовать для этой учётной записи.",
|
||||||
|
"Your organisation has reached the number of accounts it is allowed.": "Ваша организация достигла допустимого числа учётных записей.",
|
||||||
|
"Something still depends on this, so the server kept it.": "От этого ещё что-то зависит, поэтому сервер это сохранил.",
|
||||||
|
"This account no longer exists. Someone may have deleted it.": "Этой учётной записи больше нет. Возможно, её кто-то удалил.",
|
||||||
|
"The password was not accepted: {reason}": "Пароль не принят: {reason}",
|
||||||
|
"The password was not accepted.": "Пароль не принят.",
|
||||||
"Go to folder…": "Перейти к папке…",
|
"Go to folder…": "Перейти к папке…",
|
||||||
"Set for everyone here. You cannot change this.": "Задано для всех здесь. Изменить нельзя.",
|
"Set for everyone here. You cannot change this.": "Задано для всех здесь. Изменить нельзя.",
|
||||||
"Export iCAL file": "Экспортировать файл iCAL",
|
"Export iCAL file": "Экспортировать файл iCAL",
|
||||||
@@ -321,7 +461,6 @@ export const catalog: Catalog = {
|
|||||||
"Busy": "Занят",
|
"Busy": "Занят",
|
||||||
"Free/busy": "Занятость",
|
"Free/busy": "Занятость",
|
||||||
"Show as": "Показывать как",
|
"Show as": "Показывать как",
|
||||||
"Availability on {date}": "Занятость на {date}",
|
|
||||||
"Count all events as busy": "Считать все события занятостью",
|
"Count all events as busy": "Считать все события занятостью",
|
||||||
"Only events I'm attending": "Только события, где я участвую",
|
"Only events I'm attending": "Только события, где я участвую",
|
||||||
"Don't include in availability": "Не учитывать в занятости",
|
"Don't include in availability": "Не учитывать в занятости",
|
||||||
@@ -360,7 +499,6 @@ export const catalog: Catalog = {
|
|||||||
"New address book": "Новая адресная книга",
|
"New address book": "Новая адресная книга",
|
||||||
"No address books yet.": "Адресных книг пока нет.",
|
"No address books yet.": "Адресных книг пока нет.",
|
||||||
"Choose from address books": "Выбрать из адресных книг",
|
"Choose from address books": "Выбрать из адресных книг",
|
||||||
"Import vCard": "Импорт vCard",
|
|
||||||
"Export all contacts": "Экспортировать все контакты",
|
"Export all contacts": "Экспортировать все контакты",
|
||||||
"Export address book": "Экспортировать эту адресную книгу",
|
"Export address book": "Экспортировать эту адресную книгу",
|
||||||
"Import contacts…": "Импортировать контакты…",
|
"Import contacts…": "Импортировать контакты…",
|
||||||
@@ -435,7 +573,6 @@ export const catalog: Catalog = {
|
|||||||
"Make ihasmail yours.": "Настройте ihasmail под себя.",
|
"Make ihasmail yours.": "Настройте ihasmail под себя.",
|
||||||
"Reading": "Чтение",
|
"Reading": "Чтение",
|
||||||
"Reading pane": "Область чтения",
|
"Reading pane": "Область чтения",
|
||||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "Поведение при чтении, отправке и в списке. Настройки хранятся в этом браузере.",
|
|
||||||
"Right of the list": "Справа от списка",
|
"Right of the list": "Справа от списка",
|
||||||
"Below the list": "Под списком",
|
"Below the list": "Под списком",
|
||||||
"Hidden (open full width)": "Скрыта (открывать во всю ширину)",
|
"Hidden (open full width)": "Скрыта (открывать во всю ширину)",
|
||||||
@@ -478,10 +615,6 @@ export const catalog: Catalog = {
|
|||||||
"Time zone": "Часовой пояс",
|
"Time zone": "Часовой пояс",
|
||||||
"Week starts on": "Неделя начинается с",
|
"Week starts on": "Неделя начинается с",
|
||||||
"Monday": "Понедельник",
|
"Monday": "Понедельник",
|
||||||
"Tuesday": "Вторник",
|
|
||||||
"Wednesday": "Среда",
|
|
||||||
"Thursday": "Четверг",
|
|
||||||
"Friday": "Пятница",
|
|
||||||
"Saturday": "Суббота",
|
"Saturday": "Суббота",
|
||||||
"Sunday": "Воскресенье",
|
"Sunday": "Воскресенье",
|
||||||
"12-hour clock (6:23 PM)": "12-часовой формат (6:23 PM)",
|
"12-hour clock (6:23 PM)": "12-часовой формат (6:23 PM)",
|
||||||
@@ -521,6 +654,8 @@ export const catalog: Catalog = {
|
|||||||
"Show labels in the sidebar": "Показывать ярлыки на боковой панели",
|
"Show labels in the sidebar": "Показывать ярлыки на боковой панели",
|
||||||
"Collapse sidebar to icons": "Свернуть боковую панель до значков",
|
"Collapse sidebar to icons": "Свернуть боковую панель до значков",
|
||||||
"Apply the theme to messages too": "Применять тему и к письмам",
|
"Apply the theme to messages too": "Применять тему и к письмам",
|
||||||
|
"Apply it even to mail that styles itself": "Применять даже к письмам с собственным оформлением",
|
||||||
|
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Почти в каждом рекламном письме и чеке где-нибудь задан цвет, поэтому настройка выше оставляет почти все такие письма на белой карточке. С этой настройкой тема накладывается поверх цветов отправителя: фон, на котором свёрстано письмо, убирается, а кнопки и цветные плашки сохраняются, чтобы текст на них оставался читаемым. Некоторые письма это не переживут без потерь — поэтому настройка отдельная.",
|
||||||
"Swiping": "Жесты смахивания",
|
"Swiping": "Жесты смахивания",
|
||||||
"Swipe left": "Смахнуть влево",
|
"Swipe left": "Смахнуть влево",
|
||||||
"Swipe right": "Смахнуть вправо",
|
"Swipe right": "Смахнуть вправо",
|
||||||
@@ -725,10 +860,8 @@ export const catalog: Catalog = {
|
|||||||
"Manage labels": "Управление ярлыками",
|
"Manage labels": "Управление ярлыками",
|
||||||
"Create “{name}”": "Создать «{name}»",
|
"Create “{name}”": "Создать «{name}»",
|
||||||
"Type a name to create your first label.": "Введите название, чтобы создать первый ярлык.",
|
"Type a name to create your first label.": "Введите название, чтобы создать первый ярлык.",
|
||||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Ярлыки — это ключевые слова IMAP, которые хранятся в самих письмах и синхронизируются с другими клиентами. Названия и цвета остаются в этом браузере.",
|
|
||||||
"New label": "Новый ярлык",
|
"New label": "Новый ярлык",
|
||||||
"Delete label": "Удалить ярлык",
|
"Delete label": "Удалить ярлык",
|
||||||
"PDF": "PDF",
|
|
||||||
"Large attachments may be rejected by some servers": "Некоторые серверы отклоняют большие вложения",
|
"Large attachments may be rejected by some servers": "Некоторые серверы отклоняют большие вложения",
|
||||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Изображения хранятся в ваших Файлах (папка «ihasmail») и вставляются при отправке.",
|
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Изображения хранятся в ваших Файлах (папка «ihasmail») и вставляются при отправке.",
|
||||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Спасибо за письмо. Я отсутствую до … и отвечу после возвращения.",
|
"Thanks for your message. I'm away until … and will reply when I'm back.": "Спасибо за письмо. Я отсутствую до … и отвечу после возвращения.",
|
||||||
@@ -801,6 +934,8 @@ export const catalog: Catalog = {
|
|||||||
"folder\u0004Junk Mail": "Спам",
|
"folder\u0004Junk Mail": "Спам",
|
||||||
"folder\u0004Important": "Важное",
|
"folder\u0004Important": "Важное",
|
||||||
"folder\u0004All mail": "Вся почта",
|
"folder\u0004All mail": "Вся почта",
|
||||||
|
"share sheet\u0004Share": "Поделиться",
|
||||||
|
"share sheet\u0004Share…": "Поделиться…",
|
||||||
"folder": "папка",
|
"folder": "папка",
|
||||||
"“{name}” moved into “{parent}”": "«{name}» перемещена в «{parent}»",
|
"“{name}” moved into “{parent}”": "«{name}» перемещена в «{parent}»",
|
||||||
"“{name}” moved to the top level": "«{name}» перемещена на верхний уровень",
|
"“{name}” moved to the top level": "«{name}» перемещена на верхний уровень",
|
||||||
@@ -809,6 +944,7 @@ export const catalog: Catalog = {
|
|||||||
"Rename folder": "Переименовать папку",
|
"Rename folder": "Переименовать папку",
|
||||||
"Search: {query}": "Поиск: {query}",
|
"Search: {query}": "Поиск: {query}",
|
||||||
"No conversation selected": "Цепочка не выбрана",
|
"No conversation selected": "Цепочка не выбрана",
|
||||||
|
"No message selected": "Письмо не выбрано",
|
||||||
"Drop here for the top level": "Перетащите сюда, чтобы вынести на верхний уровень",
|
"Drop here for the top level": "Перетащите сюда, чтобы вынести на верхний уровень",
|
||||||
|
|
||||||
// ── Longer prose ───────────────────────────────────────────────────
|
// ── Longer prose ───────────────────────────────────────────────────
|
||||||
@@ -817,6 +953,7 @@ export const catalog: Catalog = {
|
|||||||
"Open the Mail view to see all shortcuts.": "Откройте раздел «Почта», чтобы увидеть все сочетания клавиш.",
|
"Open the Mail view to see all shortcuts.": "Откройте раздел «Почта», чтобы увидеть все сочетания клавиш.",
|
||||||
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Сочетания клавиш в стиле Gmail всегда включены. Нажмите {key} в любом месте, чтобы увидеть этот список.",
|
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Сочетания клавиш в стиле Gmail всегда включены. Нажмите {key} в любом месте, чтобы увидеть этот список.",
|
||||||
"Select a conversation to read it here · Press {key} for shortcuts": "Выберите цепочку, чтобы прочитать её здесь · {key} — сочетания клавиш",
|
"Select a conversation to read it here · Press {key} for shortcuts": "Выберите цепочку, чтобы прочитать её здесь · {key} — сочетания клавиш",
|
||||||
|
"Select a message to read it here · Press {key} for shortcuts": "Выберите письмо, чтобы прочитать его здесь · {key} — сочетания клавиш",
|
||||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Совет: нажмите {key} на цепочке, чтобы присвоить ярлыки. Ищите через {operator}.",
|
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Совет: нажмите {key} на цепочке, чтобы присвоить ярлыки. Ищите через {operator}.",
|
||||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Быстрая и удобная веб-почта с открытым кодом для {server}, построенная на JMAP.",
|
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Быстрая и удобная веб-почта с открытым кодом для {server}, построенная на JMAP.",
|
||||||
"Defaults for the calendar views and new events.": "Значения по умолчанию для видов календаря и новых событий.",
|
"Defaults for the calendar views and new events.": "Значения по умолчанию для видов календаря и новых событий.",
|
||||||
@@ -858,11 +995,12 @@ export const catalog: Catalog = {
|
|||||||
"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 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 переведён, поэтому список растёт вместе с переводами, а не опережает их: язык без текстов заставил бы страницу утверждать, что она написана на языке, которым не является.",
|
||||||
"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}.",
|
||||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} — это палитра с {site}, с которой начинает новая учётная запись. Тема тёмная, поэтому везде, где это важно, считается тёмной, а акцентный цвет ниже применяется поверх неё.",
|
|
||||||
"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; то, что этой сборке нужно от сервера, указано строкой выше.",
|
"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; то, что этой сборке нужно от сервера, указано строкой выше.",
|
||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Новое письмо",
|
"New message": "Новое письмо",
|
||||||
|
"New mail": "Новое письмо",
|
||||||
|
"Could not do that — open ihasmail and try again": "Не удалось — откройте ihasmail и повторите попытку",
|
||||||
"Sending…": "Отправка…",
|
"Sending…": "Отправка…",
|
||||||
"Saving…": "Сохранение…",
|
"Saving…": "Сохранение…",
|
||||||
"Error": "Ошибка",
|
"Error": "Ошибка",
|
||||||
@@ -903,6 +1041,7 @@ export const catalog: Catalog = {
|
|||||||
"Could not copy the address": "Не удалось скопировать адрес",
|
"Could not copy the address": "Не удалось скопировать адрес",
|
||||||
"Could not empty folder: {error}": "Не удалось очистить папку: {error}",
|
"Could not empty folder: {error}": "Не удалось очистить папку: {error}",
|
||||||
"Could not load source: {error}": "Не удалось загрузить исходный текст: {error}",
|
"Could not load source: {error}": "Не удалось загрузить исходный текст: {error}",
|
||||||
|
"Could not share: {error}": "Не удалось поделиться: {error}",
|
||||||
"Could not mark as read: {error}": "Не удалось отметить как прочитанное: {error}",
|
"Could not mark as read: {error}": "Не удалось отметить как прочитанное: {error}",
|
||||||
"Could not save draft: {error}": "Не удалось сохранить черновик: {error}",
|
"Could not save draft: {error}": "Не удалось сохранить черновик: {error}",
|
||||||
"Could not save filter: {error}": "Не удалось сохранить фильтр: {error}",
|
"Could not save filter: {error}": "Не удалось сохранить фильтр: {error}",
|
||||||
@@ -1111,7 +1250,7 @@ export const catalog: Catalog = {
|
|||||||
"Date received": "Дата получения",
|
"Date received": "Дата получения",
|
||||||
"Date sent": "Дата отправки",
|
"Date sent": "Дата отправки",
|
||||||
"Day view": "День",
|
"Day view": "День",
|
||||||
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula, Gruvbox, Rosé Pine и Tokyo Night созданы своими проектами и используются по лицензии MIT; оттенки между опубликованными цветами выведены из них, и каждый проверен на контраст. Акцентный цвет ниже по-прежнему применяется поверх любой из тем.",
|
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "Палитры, названные в честь другого проекта, созданы этим проектом и используются по его собственной лицензии; оттенки между опубликованными цветами выводятся расчётом, и каждый из них проверяется на контраст. Акцентный цвет ниже по-прежнему применяется поверх любой из них.",
|
||||||
"Earlier": "Раньше",
|
"Earlier": "Раньше",
|
||||||
"Every folder": "Все папки",
|
"Every folder": "Все папки",
|
||||||
"Everyone addressed will receive this.": "Это получат все указанные адресаты.",
|
"Everyone addressed will receive this.": "Это получат все указанные адресаты.",
|
||||||
@@ -1212,6 +1351,14 @@ export const catalog: Catalog = {
|
|||||||
"Throw away your changes?": "Отбросить изменения?",
|
"Throw away your changes?": "Отбросить изменения?",
|
||||||
"Today, in your date format": "Сегодня, в вашем формате даты",
|
"Today, in your date format": "Сегодня, в вашем формате даты",
|
||||||
"Unread first": "Сначала непрочитанные",
|
"Unread first": "Сначала непрочитанные",
|
||||||
|
"Read first": "Сначала прочитанные",
|
||||||
|
"Unstarred first": "Сначала неотмеченные",
|
||||||
|
"Smallest first": "Сначала маленькие",
|
||||||
|
"Z to A": "От Я до А",
|
||||||
|
"A to Z": "От А до Я",
|
||||||
|
"It reads {shown} but goes to {actual}.": "Показано {shown}, но ссылка ведёт на {actual}.",
|
||||||
|
"The full address is {href}.": "Полный адрес: {href}.",
|
||||||
|
"This message came from {domain}, which is outside your organisation.": "Это письмо пришло с {domain} — за пределами вашей организации.",
|
||||||
"Unsaved changes": "Несохранённые изменения",
|
"Unsaved changes": "Несохранённые изменения",
|
||||||
"View as": "Показывать как",
|
"View as": "Показывать как",
|
||||||
"Warnings": "Предупреждения",
|
"Warnings": "Предупреждения",
|
||||||
@@ -1257,8 +1404,119 @@ export const catalog: Catalog = {
|
|||||||
"This message was sent automatically, so no read receipt is offered.": "Это письмо отправлено автоматически, поэтому уведомление о прочтении не предлагается.",
|
"This message was sent automatically, so no read receipt is offered.": "Это письмо отправлено автоматически, поэтому уведомление о прочтении не предлагается.",
|
||||||
"This server will not hold a message longer than {span}.": "Этот сервер не удерживает письмо дольше чем {span}.",
|
"This server will not hold a message longer than {span}.": "Этот сервер не удерживает письмо дольше чем {span}.",
|
||||||
"Upload failed": "Не удалось загрузить",
|
"Upload failed": "Не удалось загрузить",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
"Every {n} {frequency}": "Каждые {n} {frequency}",
|
||||||
|
"Monthly": "Ежемесячно",
|
||||||
|
"Monthly on day {days}": "Ежемесячно {days}-го числа",
|
||||||
|
"Monthly on the {ordinal} {weekday}": "Ежемесячно в {ordinal} {weekday}",
|
||||||
|
"Monthly on {weekday}": "Ежемесячно в {weekday}",
|
||||||
|
"Weekly": "Еженедельно",
|
||||||
|
"Weekly on {days}": "Еженедельно в {days}",
|
||||||
|
"add {flag}": "добавить {flag}",
|
||||||
|
"always": "всегда",
|
||||||
|
"body contains \"{value}\"": "текст содержит \"{value}\"",
|
||||||
|
"body does not contain \"{value}\"": "текст не содержит \"{value}\"",
|
||||||
|
"delete it": "удалить",
|
||||||
|
"fifth": "пятый",
|
||||||
|
"first": "первый",
|
||||||
|
"forward to {address}": "переслать на {address}",
|
||||||
|
"fourth": "четвёртый",
|
||||||
|
"keep it": "оставить",
|
||||||
|
"last": "последний",
|
||||||
|
"mark it read": "пометить прочитанным",
|
||||||
|
"move to {folder}": "переместить в {folder}",
|
||||||
|
"reject it": "отклонить",
|
||||||
|
"remove {flag}": "убрать {flag}",
|
||||||
|
"second": "второй",
|
||||||
|
"size is over {n} KB": "размер больше {n} КБ",
|
||||||
|
"size is under {n} KB": "размер меньше {n} КБ",
|
||||||
|
"star it": "отметить",
|
||||||
|
"stop": "остановить",
|
||||||
|
"third": "третий",
|
||||||
|
"{header} address {op} \"{value}\"": "адрес {header} {op} \"{value}\"",
|
||||||
|
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
|
||||||
|
"{rule}, until {date}": "{rule}, до {date}",
|
||||||
|
"{tests} → {actions}": "{tests} → {actions}",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"This cannot be undone.": "Это действие нельзя отменить.",
|
||||||
|
"Some could not be deleted: {error}": "Некоторые не удалось удалить: {error}",
|
||||||
|
"It was not deleted": "Контакт не был удалён",
|
||||||
|
"Empty address book": "Очистить эту адресную книгу",
|
||||||
|
"There is nothing in it to delete": "В ней нечего удалять",
|
||||||
|
"Empty “{name}”?": "Очистить «{name}»?",
|
||||||
|
"Delete them": "Удалить их",
|
||||||
|
"Nothing was deleted": "Ничего не удалено",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "Сертификат распространяется на",
|
||||||
|
"Details": "Подробности",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Прежние письма с этого адреса подписывал {previous}. Это подписано {current}.",
|
||||||
|
"Fingerprint": "Отпечаток",
|
||||||
|
"Hide details": "Скрыть подробности",
|
||||||
|
"Issued by": "Кем выдан",
|
||||||
|
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Письмо подписано OpenPGP, а ihasmail не может получить открытый ключ отправителя.",
|
||||||
|
"It uses a signature algorithm ihasmail cannot check yet.": "Использован алгоритм подписи, который ihasmail пока не умеет проверять.",
|
||||||
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Подпись сделана сертификатом, принадлежащим {name}, который не покрывает этот адрес.",
|
||||||
|
"Previous fingerprint": "Прежний отпечаток",
|
||||||
|
"Signed at": "Подписано",
|
||||||
|
"Signed by {name} — the same signer as before.": "Подписано {name} — тот же подписавший, что и раньше.",
|
||||||
|
"Signed by {name}, seen here for the first time.": "Подписано {name}, встречается здесь впервые.",
|
||||||
|
"Signer": "Подписавший",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Это может быть обновлённый сертификат, а может быть и другой человек. Прежде чем доверять, свяжитесь с отправителем другим способом.",
|
||||||
|
"The certificate has expired.": "Срок действия сертификата истёк.",
|
||||||
|
"The certificate is not valid yet.": "Сертификат ещё не действителен.",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Письмо не совпадает с тем, что было подписано — его изменили после подписания или повредили в пути.",
|
||||||
|
"The signature carries no certificate that can be read.": "В подписи нет сертификата, который удалось бы прочитать.",
|
||||||
|
"The signature could not be read.": "Подпись не удалось прочитать.",
|
||||||
|
"The signature does not match the certificate sent with it.": "Подпись не соответствует приложенному сертификату.",
|
||||||
|
"The signature is not for this sender.": "Подпись не принадлежит этому отправителю.",
|
||||||
|
"The signed part is missing either the message or the signature.": "В подписанной части не хватает либо письма, либо подписи.",
|
||||||
|
"The signer has changed.": "Подписавший изменился.",
|
||||||
|
"This message is signed, and ihasmail could not check the signature.": "Это письмо подписано, и ihasmail не смог проверить подпись.",
|
||||||
|
"This signature does not check out.": "Эта подпись не сходится.",
|
||||||
|
"Valid until": "Действует до",
|
||||||
|
"a different certificate": "другим сертификатом",
|
||||||
|
"an unnamed signer": "неназванным подписавшим",
|
||||||
|
"as claimed by the signer": "по словам подписавшего",
|
||||||
|
"first seen {date}": "впервые замечен {date}",
|
||||||
|
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail сообщит, если следующее письмо с этого адреса подпишет кто-то другой.",
|
||||||
|
"itself, or an issuer it does not name": "самим собой или неназванным издателем",
|
||||||
|
"no address": "нет адреса",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── Administration: domains ────────────────────────────────────
|
||||||
|
"{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 будут удалены. Отменить это нельзя." },
|
||||||
|
"{n} domains": { one: "{n} домен", few: "{n} домена", many: "{n} доменов", other: "{n} домена" },
|
||||||
|
"{n} mailing lists": { one: "{n} список рассылки", few: "{n} списка рассылки", many: "{n} списков рассылки", other: "{n} списка рассылки" },
|
||||||
|
"{n} DKIM keys": { one: "{n} ключ DKIM", few: "{n} ключа DKIM", many: "{n} ключей DKIM", other: "{n} ключа DKIM" },
|
||||||
|
"{n} other items": { one: "{n} другой объект", few: "{n} других объекта", many: "{n} других объектов", other: "{n} другого объекта" },
|
||||||
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
"{n} accounts": { one: "{n} учётная запись", few: "{n} учётные записи", many: "{n} учётных записей", other: "{n} учётной записи" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Delete {n} items": { one: "Удалить {n} объект", few: "Удалить {n} объекта", many: "Удалить {n} объектов", other: "Удалить {n} объекта" },
|
||||||
|
"Delete {n} items?": { one: "Удалить {n} объект?", few: "Удалить {n} объекта?", many: "Удалить {n} объектов?", other: "Удалить {n} объекта?" },
|
||||||
|
"Move {n} items": { one: "Переместить {n} объект", few: "Переместить {n} объекта", many: "Переместить {n} объектов", other: "Переместить {n} объекта" },
|
||||||
|
"Move {n} items…": { one: "Переместить {n} объект…", few: "Переместить {n} объекта…", many: "Переместить {n} объектов…", other: "Переместить {n} объекта…" },
|
||||||
|
"The event runs {n} days longer than this shows.": { one: "Событие длится на {n} день дольше, чем показано здесь.", few: "Событие длится на {n} дня дольше, чем показано здесь.", many: "Событие длится на {n} дней дольше, чем показано здесь.", other: "Событие длится на {n} дня дольше, чем показано здесь." },
|
||||||
|
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} участник не на этом сервере, поэтому сведений о занятости для него нет.", few: "{n} участника не на этом сервере, поэтому сведений о занятости для них нет.", many: "{n} участников не на этом сервере, поэтому сведений о занятости для них нет.", other: "{n} участника не на этом сервере, поэтому сведений о занятости для них нет." },
|
||||||
|
"{n} items selected": { one: "Выбран {n} объект", few: "Выбрано {n} объекта", many: "Выбрано {n} объектов", other: "Выбрано {n} объекта" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Every {n} days": { one: "Каждый день", few: "Каждые {n} дня", many: "Каждые {n} дней", other: "Каждые {n} дня" },
|
||||||
|
"Every {n} months": { one: "Каждый месяц", few: "Каждые {n} месяца", many: "Каждые {n} месяцев", other: "Каждые {n} месяца" },
|
||||||
|
"Every {n} months on day {days}": { one: "Каждый месяц {days}-го числа", few: "Каждые {n} месяца {days}-го числа", many: "Каждые {n} месяцев {days}-го числа", other: "Каждые {n} месяца {days}-го числа" },
|
||||||
|
"Every {n} months on the {ordinal} {weekday}": { one: "Каждый месяц в {ordinal} {weekday}", few: "Каждые {n} месяца в {ordinal} {weekday}", many: "Каждые {n} месяцев в {ordinal} {weekday}", other: "Каждые {n} месяца в {ordinal} {weekday}" },
|
||||||
|
"Every {n} months on {weekday}": { one: "Каждый месяц в {weekday}", few: "Каждые {n} месяца в {weekday}", many: "Каждые {n} месяцев в {weekday}", other: "Каждые {n} месяца в {weekday}" },
|
||||||
|
"Every {n} weeks": { one: "Каждую неделю", few: "Каждые {n} недели", many: "Каждые {n} недель", other: "Каждые {n} недели" },
|
||||||
|
"Every {n} weeks on {days}": { one: "Каждую неделю в {days}", few: "Каждые {n} недели в {days}", many: "Каждые {n} недель в {days}", other: "Каждые {n} недели в {days}" },
|
||||||
|
"Every {n} years": { one: "Каждый год", few: "Каждые {n} года", many: "Каждые {n} лет", other: "Каждые {n} года" },
|
||||||
|
"{rule}, {n} times": { one: "{rule}, {n} раз", few: "{rule}, {n} раза", many: "{rule}, {n} раз", other: "{rule}, {n} раза" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
"Move {n} messages to Trash?": { one: "Переместить {n} письмо в корзину?", few: "Переместить {n} письма в корзину?", many: "Переместить {n} писем в корзину?", other: "Переместить {n} письма в корзину?" },
|
"Move {n} messages to Trash?": { one: "Переместить {n} письмо в корзину?", few: "Переместить {n} письма в корзину?", many: "Переместить {n} писем в корзину?", other: "Переместить {n} письма в корзину?" },
|
||||||
"{n} days": { one: "{n} день", few: "{n} дня", many: "{n} дней", other: "{n} дня" },
|
"{n} days": { one: "{n} день", few: "{n} дня", many: "{n} дней", other: "{n} дня" },
|
||||||
@@ -1270,8 +1528,7 @@ export const catalog: Catalog = {
|
|||||||
"Your administrator changed {n} settings": { one: "Администратор изменил {n} настройку", few: "Администратор изменил {n} настройки", many: "Администратор изменил {n} настроек", other: "Администратор изменил {n} настройки" },
|
"Your administrator changed {n} settings": { one: "Администратор изменил {n} настройку", few: "Администратор изменил {n} настройки", many: "Администратор изменил {n} настроек", other: "Администратор изменил {n} настройки" },
|
||||||
"Exported {n} events": { one: "Экспортировано {n} событие", few: "Экспортировано {n} события", many: "Экспортировано {n} событий", other: "Экспортировано {n} события" },
|
"Exported {n} events": { one: "Экспортировано {n} событие", few: "Экспортировано {n} события", many: "Экспортировано {n} событий", other: "Экспортировано {n} события" },
|
||||||
"Imported {n} events": { one: "Импортировано {n} событие", few: "Импортировано {n} события", many: "Импортировано {n} событий", other: "Импортировано {n} события" },
|
"Imported {n} events": { one: "Импортировано {n} событие", few: "Импортировано {n} события", many: "Импортировано {n} событий", other: "Импортировано {n} события" },
|
||||||
"Already here: {n} events, nothing imported": { one: "Уже есть: {n} событие, ничего не импортировано", few: "Уже есть: {n} события, ничего не импортировано", many: "Уже есть: {n} событий, ничего не импортировано", other: "Уже есть: {n} события, ничего не импортировано" },
|
"Updated {n} events, nothing new": { one: "Обновлено {n} событие, новых нет", few: "Обновлено {n} события, новых нет", many: "Обновлено {n} событий, новых нет", other: "Обновлено {n} события, новых нет" },
|
||||||
"{n} were already here": { one: "{n} уже было здесь", few: "{n} уже были здесь", many: "{n} уже были здесь", other: "{n} уже были здесь" },
|
|
||||||
/*
|
/*
|
||||||
* Three forms, which is the whole reason plural() takes a map rather than
|
* Three forms, which is the whole reason plural() takes a map rather than
|
||||||
* (one, other). Intl.PluralRules picks: 1 is `one`, 2-4 are `few`, 5-20
|
* (one, other). Intl.PluralRules picks: 1 is `one`, 2-4 are `few`, 5-20
|
||||||
@@ -1298,5 +1555,10 @@ export const catalog: Catalog = {
|
|||||||
"Marked {n} messages as read": { one: "{n} письмо отмечено как прочитанное", few: "{n} письма отмечены как прочитанные", many: "{n} писем отмечены как прочитанные", other: "{n} письма отмечены как прочитанные" },
|
"Marked {n} messages as read": { one: "{n} письмо отмечено как прочитанное", few: "{n} письма отмечены как прочитанные", many: "{n} писем отмечены как прочитанные", other: "{n} письма отмечены как прочитанные" },
|
||||||
"in {n} folders": { one: "в {n} папке", few: "в {n} папках", many: "в {n} папках", other: "в {n} папках" },
|
"in {n} folders": { one: "в {n} папке", few: "в {n} папках", many: "в {n} папках", other: "в {n} папках" },
|
||||||
"Deleted {n} messages": { one: "Удалено {n} письмо", few: "Удалено {n} письма", many: "Удалено {n} писем", other: "Удалено {n} письма" },
|
"Deleted {n} messages": { one: "Удалено {n} письмо", few: "Удалено {n} письма", many: "Удалено {n} писем", other: "Удалено {n} письма" },
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"Delete {n} contacts?": { one: "Удалить {n} контакт?", few: "Удалить {n} контакта?", many: "Удалить {n} контактов?", other: "Удалить {n} контакта?" },
|
||||||
|
"Deleted {n} contacts": { one: "Удалён {n} контакт", few: "Удалено {n} контакта", many: "Удалено {n} контактов", other: "Удалено {n} контакта" },
|
||||||
|
"{n} contacts will be deleted. This cannot be undone.": { one: "Будет удалён {n} контакт. Это действие нельзя отменить.", few: "Будет удалено {n} контакта. Это действие нельзя отменить.", many: "Будет удалено {n} контактов. Это действие нельзя отменить.", other: "Будет удалено {n} контакта. Это действие нельзя отменить." },
|
||||||
|
"{n} were also in other address books and were only removed from this one": { one: "{n} контакт также был в другой адресной книге и удалён только из этой", few: "{n} контакта также были в других адресных книгах и удалены только из этой", many: "{n} контактов также были в других адресных книгах и удалены только из этой", other: "{n} контакта также были в других адресных книгах и удалены только из этой" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+275
-13
@@ -43,6 +43,146 @@ import type { Catalog } from "@/lib/i18n";
|
|||||||
*/
|
*/
|
||||||
export const catalog: Catalog = {
|
export const catalog: Catalog = {
|
||||||
strings: {
|
strings: {
|
||||||
|
// ── Administration: domains ────────────────────────────────────
|
||||||
|
"Domains": "Домени",
|
||||||
|
"By hand": "Вручну",
|
||||||
|
"Signing": "Підписує",
|
||||||
|
"Published, not signing yet": "Опубліковано, ще не підписує",
|
||||||
|
"Retiring": "Виводиться з роботи",
|
||||||
|
"Retired": "Виведено з роботи",
|
||||||
|
"This domain no longer exists. Someone may have removed it.": "Цього домену більше немає. Можливо, його хтось видалив.",
|
||||||
|
"That doesn't look like a domain name, such as example.com.": "Це не схоже на ім'я домену на кшталт example.com.",
|
||||||
|
"Added {name}. Its DNS records are ready to copy.": "Домен {name} додано. Його DNS-записи готові до копіювання.",
|
||||||
|
"Saved {name}": "Домен {name} збережено",
|
||||||
|
"Add domain": "Додати домен",
|
||||||
|
"Added {date}": "Додано {date}",
|
||||||
|
"This domain is disabled on the server.": "Цей домен вимкнено на сервері.",
|
||||||
|
"Your role lets you view domains but not change them.": "Ваша роль дозволяє переглядати домени, але не змінювати їх.",
|
||||||
|
"New domains sign their mail with DKIM keys the server creates and rotates. Its DNS records appear here once it's added.": "Нові домени підписують пошту ключами DKIM, які сервер створює й змінює сам. DNS-записи з'являться тут після додавання домену.",
|
||||||
|
"Other names": "Інші імена",
|
||||||
|
"Delivery": "Доставлення",
|
||||||
|
"Catch-all address": "Адреса для всієї пошти",
|
||||||
|
"Mail to an address nobody has on this domain is delivered here. Leave it empty to refuse that mail.": "Сюди доставляються листи на адреси цього домену, яких ні в кого немає. Залиште порожнім, щоб відхиляти такі листи.",
|
||||||
|
"Plus addressing": "Адреси з «+»",
|
||||||
|
"Set by a custom rule on the server.": "Задано власним правилом на сервері.",
|
||||||
|
"Mail to name+anything@ is delivered to name@.": "Листи на ім'я+будь-що@ доставляються на ім'я@.",
|
||||||
|
"DNS records": "DNS-записи",
|
||||||
|
"Published automatically through {provider}.": "Публікуються автоматично через {provider}.",
|
||||||
|
"Published automatically by the server.": "Публікуються сервером автоматично.",
|
||||||
|
"Add these where this domain's DNS is hosted. Mail isn't delivered or trusted until they're in place.": "Додайте їх там, де розміщено DNS цього домену. Поки їх немає, пошта не доставляється й не вважається надійною.",
|
||||||
|
"Copy {type} record for {name}": "Скопіювати запис {type} для {name}",
|
||||||
|
"Copy value": "Скопіювати значення",
|
||||||
|
"Copied the zone file": "Файл зони скопійовано",
|
||||||
|
"Copy all as a zone file": "Скопіювати все як файл зони",
|
||||||
|
"The server returned no records for this domain.": "Сервер не повернув записів для цього домену.",
|
||||||
|
"DKIM keys": "Ключі DKIM",
|
||||||
|
"The server creates and rotates these keys itself.": "Сервер створює й змінює ці ключі сам.",
|
||||||
|
"These keys are managed by hand on the server.": "Цими ключами керують на сервері вручну.",
|
||||||
|
"No DKIM keys, so mail from this domain isn't signed and is more likely to be marked as spam.": "Ключів DKIM немає, тому пошта з цього домену не підписується й частіше потрапляє до спаму.",
|
||||||
|
"Managed by the server": "Керується сервером",
|
||||||
|
"Certificate": "Сертифікат",
|
||||||
|
"Another name for this domain": "Інше ім'я для цього домену",
|
||||||
|
"Mail to the same address at any of these names reaches the same account. Changes apply when you save.": "Листи на ту саму адресу під будь-яким із цих імен потрапляють до того самого облікового запису. Зміни наберуть чинності після збереження.",
|
||||||
|
"Its DKIM keys have to be removed first, and your role can't remove them.": "Спершу треба видалити його ключі DKIM, а ваша роль цього не дозволяє.",
|
||||||
|
"The server stops accepting mail for this domain.": "Сервер перестане приймати пошту для цього домену.",
|
||||||
|
"Remove domain…": "Видалити домен…",
|
||||||
|
"Remove {name}?": "Видалити {name}?",
|
||||||
|
"Removed {name}": "Домен {name} видалено",
|
||||||
|
"The server kept the domain: it is still used by {things}.": "Сервер зберіг домен: його ще використовують {things}.",
|
||||||
|
"Remove domain": "Видалити домен",
|
||||||
|
"The server stops accepting mail for this domain. This can't be undone.": "Сервер перестане приймати пошту для цього домену. Скасувати це неможливо.",
|
||||||
|
"Where your addresses live, and the DNS records that let mail arrive and be trusted.": "Де розміщено ваші адреси та DNS-записи, завдяки яким пошта доходить і викликає довіру.",
|
||||||
|
"Search domains": "Пошук доменів",
|
||||||
|
"No domains match": "Немає відповідних доменів",
|
||||||
|
"No domains yet": "Доменів ще немає",
|
||||||
|
"DKIM": "DKIM",
|
||||||
|
"Tenant": "Орендар",
|
||||||
|
"Disabled": "Вимкнено",
|
||||||
|
"also {names}": "також {names}",
|
||||||
|
"The server did not say whether the domain was created.": "Сервер не повідомив, чи створено домен.",
|
||||||
|
// ── Administration: refusals ───────────────────────────────────
|
||||||
|
"That isn't a valid domain name. Use a name such as example.com, on a real top-level domain.": "Це недійсне ім'я домену. Вкажіть ім'я на кшталт example.com зі справжнім доменом верхнього рівня.",
|
||||||
|
"That isn't a valid email address. Use a full address, such as [email protected].": "Це недійсна адреса електронної пошти. Вкажіть повну адресу, наприклад [email protected].",
|
||||||
|
"That isn't a valid address. Use letters, numbers, dots, hyphens or underscores before the @.": "Це недійсна адреса. Перед @ використовуйте літери, цифри, крапки, дефіси або підкреслення.",
|
||||||
|
"That isn't a valid host name or IP address.": "Це недійсне ім'я хоста чи IP-адреса.",
|
||||||
|
"A required value was left empty.": "Обов'язкове значення не заповнено.",
|
||||||
|
"Administration is turned off on this installation.": "Адміністрування вимкнено в цьому встановленні.",
|
||||||
|
"The mail server could not carry out the request ({code}).": "Поштовий сервер не зміг виконати запит ({code}).",
|
||||||
|
"You can't give an account permissions your own role doesn't have.": "Не можна надати обліковому запису дозволи, яких немає у вашої ролі.",
|
||||||
|
"This account signs in through an external directory, so its password can't be set here.": "Цей обліковий запис входить через зовнішній каталог, тому його пароль не можна задати тут.",
|
||||||
|
"The server's licence allows no more accounts.": "Ліцензія сервера не дозволяє нових облікових записів.",
|
||||||
|
"That domain name is already in use on this server, as a domain or another domain's other name.": "Це ім'я домену вже використовується на сервері — як домен або як інше ім'я іншого домену.",
|
||||||
|
"Your organisation has reached the number of domains it is allowed.": "Ваша організація досягла дозволеної кількості доменів.",
|
||||||
|
"That is more than the mail server accepts in one change.": "Це більше, ніж поштовий сервер приймає за одну зміну.",
|
||||||
|
"The mail server rejected one of the values. Check what you entered and try again.": "Поштовий сервер відхилив одне зі значень. Перевірте введені дані й спробуйте ще раз.",
|
||||||
|
"The mail server refused the change ({code}).": "Поштовий сервер відхилив зміну ({code}).",
|
||||||
|
// ── Administration: accounts ───────────────────────────────────
|
||||||
|
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Лише на пристрої, позначеному як ваш. Увійдіть знову, позначивши «Це мій власний пристрій».",
|
||||||
|
"Change your own password in {settings}.": "Власний пароль можна змінити в розділі {settings}.",
|
||||||
|
"Administration": "Адміністрування",
|
||||||
|
"Directory": "Каталог",
|
||||||
|
"User": "Користувач",
|
||||||
|
"Administrator": "Адміністратор",
|
||||||
|
"Custom role": "Власна роль",
|
||||||
|
"New account": "Новий обліковий запис",
|
||||||
|
"The people who sign in to mail on the domains you manage.": "Люди, які входять у пошту на доменах, якими ви керуєте.",
|
||||||
|
"Search by name or address": "Пошук за іменем або адресою",
|
||||||
|
"Search accounts": "Пошук облікових записів",
|
||||||
|
"No accounts match": "Немає відповідних облікових записів",
|
||||||
|
"No accounts yet": "Облікових записів ще немає",
|
||||||
|
"Nothing on your domains matches “{query}”.": "На ваших доменах немає збігів із «{query}».",
|
||||||
|
"Open {address}": "Відкрити {address}",
|
||||||
|
"{from}–{to} of {total}": "{from}–{to} із {total}",
|
||||||
|
"Previous page": "Попередня сторінка",
|
||||||
|
"Next page": "Наступна сторінка",
|
||||||
|
"Storage": "Сховище",
|
||||||
|
"Groups": "Групи",
|
||||||
|
"{used} · no limit": "{used} · без обмеження",
|
||||||
|
"Profile": "Профіль",
|
||||||
|
"Domain": "Домен",
|
||||||
|
"No domains are available to create an account on.": "Немає доменів, на яких можна створити обліковий запис.",
|
||||||
|
"Sign-in": "Вхід",
|
||||||
|
"Other addresses": "Інші адреси",
|
||||||
|
"Not in any group": "Не входить до жодної групи",
|
||||||
|
"You can't change your own role.": "Ви не можете змінити власну роль.",
|
||||||
|
"Only roles whose permissions you hold yourself are offered. On an account inside a tenant, Administrator means administrator of that tenant.": "Пропонуються лише ролі, усі дозволи яких маєте ви самі. Для облікового запису всередині орендаря «Адміністратор» означає адміністратора цього орендаря.",
|
||||||
|
"Limit in GB": "Обмеження в ГБ",
|
||||||
|
"No limit": "Без обмеження",
|
||||||
|
"This account has permissions yours doesn't, so you can view it but not change it.": "Цей обліковий запис має дозволи, яких немає у вашого, тому його можна переглядати, але не змінювати.",
|
||||||
|
"Your role lets you view accounts but not change them.": "Ваша роль дозволяє переглядати облікові записи, але не змінювати їх.",
|
||||||
|
"This account has permissions yours doesn't.": "Цей обліковий запис має дозволи, яких немає у вашого.",
|
||||||
|
"You can't delete the account you're signed in with.": "Не можна видалити обліковий запис, під яким ви ввійшли.",
|
||||||
|
"Create account": "Створити обліковий запис",
|
||||||
|
"An account needs an address.": "Обліковому запису потрібна адреса.",
|
||||||
|
"Created {address}": "Обліковий запис {address} створено",
|
||||||
|
"Saved {address}": "Обліковий запис {address} збережено",
|
||||||
|
"Generate a password": "Згенерувати пароль",
|
||||||
|
"Pass it on some way other than email to this address.": "Передайте його будь-яким способом, окрім листа на цю адресу.",
|
||||||
|
"This account has no password. It may sign in through a directory or single sign-on.": "Цей обліковий запис не має пароля. Можливо, вхід виконується через каталог або єдиний вхід.",
|
||||||
|
"Set a new password…": "Задати новий пароль…",
|
||||||
|
"{name} will be signed out of every app and device using the old password.": "Для {name} буде виконано вихід у всіх застосунках і на всіх пристроях, де використовується старий пароль.",
|
||||||
|
"New password set for {address}": "Новий пароль для {address} задано",
|
||||||
|
"Set password": "Задати пароль",
|
||||||
|
"Remove {address}": "Видалити {address}",
|
||||||
|
"New address": "Нова адреса",
|
||||||
|
"another name": "інше ім'я",
|
||||||
|
"Mail to these addresses is delivered to this account. Changes apply when you save.": "Листи на ці адреси доставляються в цей обліковий запис. Зміни наберуть чинності після збереження.",
|
||||||
|
"Deletes the mailbox and everything in it.": "Видаляє поштову скриньку й усе, що в ній.",
|
||||||
|
"Delete account…": "Видалити обліковий запис…",
|
||||||
|
"Delete {address}?": "Видалити {address}?",
|
||||||
|
"This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.": "Буде видалено пошту, календарі, контакти й файли цього облікового запису. Сервер видалить їх у фоновому режимі, скасувати це неможливо.",
|
||||||
|
"Type {address} to confirm": "Введіть {address} для підтвердження",
|
||||||
|
"Delete account": "Видалити обліковий запис",
|
||||||
|
"Deleted {address}": "Обліковий запис {address} видалено",
|
||||||
|
"The server did not say whether the account was created.": "Сервер не повідомив, чи створено обліковий запис.",
|
||||||
|
"The mail server refused this. Your role may not allow it.": "Поштовий сервер відхилив цю дію. Можливо, ваша роль її не дозволяє.",
|
||||||
|
"That address is already in use on this server, as an account, a list or an alias.": "Ця адреса вже використовується на сервері — обліковим записом, списком або псевдонімом.",
|
||||||
|
"One of the chosen domain, role or group can't be used for this account.": "Вибраний домен, роль або групу не можна використати для цього облікового запису.",
|
||||||
|
"Your organisation has reached the number of accounts it is allowed.": "Ваша організація досягла дозволеної кількості облікових записів.",
|
||||||
|
"Something still depends on this, so the server kept it.": "Від цього ще щось залежить, тому сервер це зберіг.",
|
||||||
|
"This account no longer exists. Someone may have deleted it.": "Цього облікового запису більше немає. Можливо, його хтось видалив.",
|
||||||
|
"The password was not accepted: {reason}": "Пароль не прийнято: {reason}",
|
||||||
|
"The password was not accepted.": "Пароль не прийнято.",
|
||||||
"Go to folder…": "Перейти до теки…",
|
"Go to folder…": "Перейти до теки…",
|
||||||
"Set for everyone here. You cannot change this.": "Задано для всіх тут. Змінити не можна.",
|
"Set for everyone here. You cannot change this.": "Задано для всіх тут. Змінити не можна.",
|
||||||
"Export iCAL file": "Експортувати файл iCAL",
|
"Export iCAL file": "Експортувати файл iCAL",
|
||||||
@@ -315,7 +455,6 @@ export const catalog: Catalog = {
|
|||||||
"Busy": "Зайнятий",
|
"Busy": "Зайнятий",
|
||||||
"Free/busy": "Зайнятість",
|
"Free/busy": "Зайнятість",
|
||||||
"Show as": "Показувати як",
|
"Show as": "Показувати як",
|
||||||
"Availability on {date}": "Зайнятість на {date}",
|
|
||||||
"Count all events as busy": "Вважати всі події зайнятістю",
|
"Count all events as busy": "Вважати всі події зайнятістю",
|
||||||
"Only events I'm attending": "Лише події, де я беру участь",
|
"Only events I'm attending": "Лише події, де я беру участь",
|
||||||
"Don't include in availability": "Не враховувати в зайнятості",
|
"Don't include in availability": "Не враховувати в зайнятості",
|
||||||
@@ -354,7 +493,6 @@ export const catalog: Catalog = {
|
|||||||
"New address book": "Нова адресна книга",
|
"New address book": "Нова адресна книга",
|
||||||
"No address books yet.": "Адресних книг поки немає.",
|
"No address books yet.": "Адресних книг поки немає.",
|
||||||
"Choose from address books": "Вибрати з адресних книг",
|
"Choose from address books": "Вибрати з адресних книг",
|
||||||
"Import vCard": "Імпорт vCard",
|
|
||||||
"Export all contacts": "Експортувати всі контакти",
|
"Export all contacts": "Експортувати всі контакти",
|
||||||
"Export address book": "Експортувати цю адресну книгу",
|
"Export address book": "Експортувати цю адресну книгу",
|
||||||
"Import contacts…": "Імпортувати контакти…",
|
"Import contacts…": "Імпортувати контакти…",
|
||||||
@@ -429,7 +567,6 @@ export const catalog: Catalog = {
|
|||||||
"Make ihasmail yours.": "Налаштуйте ihasmail під себе.",
|
"Make ihasmail yours.": "Налаштуйте ihasmail під себе.",
|
||||||
"Reading": "Читання",
|
"Reading": "Читання",
|
||||||
"Reading pane": "Область читання",
|
"Reading pane": "Область читання",
|
||||||
"Reading, sending and list behaviour. Settings are stored in this browser.": "Поведінка під час читання, надсилання та в списку. Налаштування зберігаються в цьому браузері.",
|
|
||||||
"Right of the list": "Праворуч від списку",
|
"Right of the list": "Праворуч від списку",
|
||||||
"Below the list": "Під списком",
|
"Below the list": "Під списком",
|
||||||
"Hidden (open full width)": "Прихована (відкривати на всю ширину)",
|
"Hidden (open full width)": "Прихована (відкривати на всю ширину)",
|
||||||
@@ -472,10 +609,6 @@ export const catalog: Catalog = {
|
|||||||
"Time zone": "Часовий пояс",
|
"Time zone": "Часовий пояс",
|
||||||
"Week starts on": "Тиждень починається з",
|
"Week starts on": "Тиждень починається з",
|
||||||
"Monday": "Понеділок",
|
"Monday": "Понеділок",
|
||||||
"Tuesday": "Вівторок",
|
|
||||||
"Wednesday": "Середа",
|
|
||||||
"Thursday": "Четвер",
|
|
||||||
"Friday": "П'ятниця",
|
|
||||||
"Saturday": "Субота",
|
"Saturday": "Субота",
|
||||||
"Sunday": "Неділя",
|
"Sunday": "Неділя",
|
||||||
"12-hour clock (6:23 PM)": "12-годинний формат (6:23 PM)",
|
"12-hour clock (6:23 PM)": "12-годинний формат (6:23 PM)",
|
||||||
@@ -515,6 +648,8 @@ export const catalog: Catalog = {
|
|||||||
"Show labels in the sidebar": "Показувати мітки на бічній панелі",
|
"Show labels in the sidebar": "Показувати мітки на бічній панелі",
|
||||||
"Collapse sidebar to icons": "Згорнути бічну панель до значків",
|
"Collapse sidebar to icons": "Згорнути бічну панель до значків",
|
||||||
"Apply the theme to messages too": "Застосовувати тему й до листів",
|
"Apply the theme to messages too": "Застосовувати тему й до листів",
|
||||||
|
"Apply it even to mail that styles itself": "Застосовувати навіть до листів із власним оформленням",
|
||||||
|
"Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.": "Майже в кожному рекламному листі та чеку десь задано колір, тому налаштування вище залишає майже всі такі листи на білій картці. Із цим налаштуванням тема накладається поверх кольорів відправника: тло, на якому зверстано лист, прибирається, а кнопки та кольорові плашки зберігаються, щоб текст на них залишався читабельним. Деякі листи цього не переживуть без втрат — тому це окреме налаштування.",
|
||||||
"Swiping": "Жести проведення",
|
"Swiping": "Жести проведення",
|
||||||
"Swipe left": "Провести ліворуч",
|
"Swipe left": "Провести ліворуч",
|
||||||
"Swipe right": "Провести праворуч",
|
"Swipe right": "Провести праворуч",
|
||||||
@@ -719,10 +854,8 @@ export const catalog: Catalog = {
|
|||||||
"Manage labels": "Керування мітками",
|
"Manage labels": "Керування мітками",
|
||||||
"Create “{name}”": "Створити «{name}»",
|
"Create “{name}”": "Створити «{name}»",
|
||||||
"Type a name to create your first label.": "Введіть назву, щоб створити першу мітку.",
|
"Type a name to create your first label.": "Введіть назву, щоб створити першу мітку.",
|
||||||
"Labels are IMAP keywords stored on your messages, so they sync to other clients. Names and colours are kept in this browser.": "Мітки — це ключові слова IMAP, які зберігаються в самих листах і синхронізуються з іншими клієнтами. Назви та кольори залишаються в цьому браузері.",
|
|
||||||
"New label": "Нова мітка",
|
"New label": "Нова мітка",
|
||||||
"Delete label": "Видалити мітку",
|
"Delete label": "Видалити мітку",
|
||||||
"PDF": "PDF",
|
|
||||||
"Large attachments may be rejected by some servers": "Деякі сервери відхиляють великі вкладення",
|
"Large attachments may be rejected by some servers": "Деякі сервери відхиляють великі вкладення",
|
||||||
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Зображення зберігаються у ваших Файлах (тека «ihasmail») і вставляються під час надсилання.",
|
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.": "Зображення зберігаються у ваших Файлах (тека «ihasmail») і вставляються під час надсилання.",
|
||||||
"Thanks for your message. I'm away until … and will reply when I'm back.": "Дякую за лист. Мене немає до … і я відповім після повернення.",
|
"Thanks for your message. I'm away until … and will reply when I'm back.": "Дякую за лист. Мене немає до … і я відповім після повернення.",
|
||||||
@@ -795,6 +928,8 @@ export const catalog: Catalog = {
|
|||||||
"folder\u0004Junk Mail": "Спам",
|
"folder\u0004Junk Mail": "Спам",
|
||||||
"folder\u0004Important": "Важливе",
|
"folder\u0004Important": "Важливе",
|
||||||
"folder\u0004All mail": "Уся пошта",
|
"folder\u0004All mail": "Уся пошта",
|
||||||
|
"share sheet\u0004Share": "Поділитися",
|
||||||
|
"share sheet\u0004Share…": "Поділитися…",
|
||||||
"folder": "тека",
|
"folder": "тека",
|
||||||
"“{name}” moved into “{parent}”": "«{name}» переміщено до «{parent}»",
|
"“{name}” moved into “{parent}”": "«{name}» переміщено до «{parent}»",
|
||||||
"“{name}” moved to the top level": "«{name}» переміщено на верхній рівень",
|
"“{name}” moved to the top level": "«{name}» переміщено на верхній рівень",
|
||||||
@@ -803,6 +938,7 @@ export const catalog: Catalog = {
|
|||||||
"Rename folder": "Перейменувати теку",
|
"Rename folder": "Перейменувати теку",
|
||||||
"Search: {query}": "Пошук: {query}",
|
"Search: {query}": "Пошук: {query}",
|
||||||
"No conversation selected": "Листування не вибрано",
|
"No conversation selected": "Листування не вибрано",
|
||||||
|
"No message selected": "Лист не вибрано",
|
||||||
"Drop here for the top level": "Перетягніть сюди, щоб винести на верхній рівень",
|
"Drop here for the top level": "Перетягніть сюди, щоб винести на верхній рівень",
|
||||||
|
|
||||||
// ── Longer prose ───────────────────────────────────────────────────
|
// ── Longer prose ───────────────────────────────────────────────────
|
||||||
@@ -811,6 +947,7 @@ export const catalog: Catalog = {
|
|||||||
"Open the Mail view to see all shortcuts.": "Відкрийте розділ «Пошта», щоб побачити всі сполучення клавіш.",
|
"Open the Mail view to see all shortcuts.": "Відкрийте розділ «Пошта», щоб побачити всі сполучення клавіш.",
|
||||||
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Сполучення клавіш у стилі Gmail завжди увімкнено. Натисніть {key} будь-де, щоб побачити цей список.",
|
"Gmail-style shortcuts are always on. Press {key} anywhere to see this list.": "Сполучення клавіш у стилі Gmail завжди увімкнено. Натисніть {key} будь-де, щоб побачити цей список.",
|
||||||
"Select a conversation to read it here · Press {key} for shortcuts": "Виберіть листування, щоб прочитати його тут · {key} — сполучення клавіш",
|
"Select a conversation to read it here · Press {key} for shortcuts": "Виберіть листування, щоб прочитати його тут · {key} — сполучення клавіш",
|
||||||
|
"Select a message to read it here · Press {key} for shortcuts": "Виберіть лист, щоб прочитати його тут · {key} — сполучення клавіш",
|
||||||
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Порада: натисніть {key} на листуванні, щоб додати мітки. Шукайте через {operator}.",
|
"Tip: press {key} on a conversation to apply labels. Search with {operator}.": "Порада: натисніть {key} на листуванні, щоб додати мітки. Шукайте через {operator}.",
|
||||||
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Швидка та зручна вебпошта з відкритим кодом для {server}, побудована на JMAP.",
|
"A fast, friendly, open-source webmail for {server}, built on JMAP.": "Швидка та зручна вебпошта з відкритим кодом для {server}, побудована на JMAP.",
|
||||||
"Defaults for the calendar views and new events.": "Значення за замовчуванням для виглядів календаря та нових подій.",
|
"Defaults for the calendar views and new events.": "Значення за замовчуванням для виглядів календаря та нових подій.",
|
||||||
@@ -852,11 +989,12 @@ export const catalog: Catalog = {
|
|||||||
"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 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, тому список зростає разом із перекладами, а не випереджає їх: мова без текстів змусила б сторінку стверджувати, що вона написана мовою, якою не є.",
|
||||||
"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}.",
|
||||||
"{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.": "{name} — це палітра з {site}, з якою починає новий обліковий запис. Тема темна, тому скрізь, де це важливо, вважається темною, а акцентний колір нижче застосовується поверх неї.",
|
|
||||||
"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; те, що цій збірці потрібно від сервера, вказано рядком вище.",
|
"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; те, що цій збірці потрібно від сервера, вказано рядком вище.",
|
||||||
|
|
||||||
// ── Composer status, calendar title ────────────────────────────────
|
// ── Composer status, calendar title ────────────────────────────────
|
||||||
"New message": "Новий лист",
|
"New message": "Новий лист",
|
||||||
|
"New mail": "Новий лист",
|
||||||
|
"Could not do that — open ihasmail and try again": "Не вдалося — відкрийте ihasmail і повторіть спробу",
|
||||||
"Sending…": "Надсилання…",
|
"Sending…": "Надсилання…",
|
||||||
"Saving…": "Збереження…",
|
"Saving…": "Збереження…",
|
||||||
"Error": "Помилка",
|
"Error": "Помилка",
|
||||||
@@ -897,6 +1035,7 @@ export const catalog: Catalog = {
|
|||||||
"Could not copy the address": "Не вдалося скопіювати адресу",
|
"Could not copy the address": "Не вдалося скопіювати адресу",
|
||||||
"Could not empty folder: {error}": "Не вдалося очистити теку: {error}",
|
"Could not empty folder: {error}": "Не вдалося очистити теку: {error}",
|
||||||
"Could not load source: {error}": "Не вдалося завантажити вихідний текст: {error}",
|
"Could not load source: {error}": "Не вдалося завантажити вихідний текст: {error}",
|
||||||
|
"Could not share: {error}": "Не вдалося поділитися: {error}",
|
||||||
"Could not mark as read: {error}": "Не вдалося позначити як прочитане: {error}",
|
"Could not mark as read: {error}": "Не вдалося позначити як прочитане: {error}",
|
||||||
"Could not save draft: {error}": "Не вдалося зберегти чернетку: {error}",
|
"Could not save draft: {error}": "Не вдалося зберегти чернетку: {error}",
|
||||||
"Could not save filter: {error}": "Не вдалося зберегти фільтр: {error}",
|
"Could not save filter: {error}": "Не вдалося зберегти фільтр: {error}",
|
||||||
@@ -1105,7 +1244,7 @@ export const catalog: Catalog = {
|
|||||||
"Date received": "Дата отримання",
|
"Date received": "Дата отримання",
|
||||||
"Date sent": "Дата надсилання",
|
"Date sent": "Дата надсилання",
|
||||||
"Day view": "День",
|
"Day view": "День",
|
||||||
"Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.": "Dracula, Gruvbox, Rosé Pine і Tokyo Night створені власними проєктами й використовуються за ліцензією MIT; відтінки між опублікованими кольорами виведені з них, і кожен перевірено на контраст. Акцентний колір нижче й далі застосовується поверх будь-якої з тем.",
|
"Palettes named after another project are that project's work, used under its own licence; the shades between their published colours are derived, and every one is checked for contrast. The accent colour below still applies over any of them.": "Палітри, названі на честь іншого проєкту, є роботою цього проєкту й використовуються за його власною ліцензією; відтінки між опублікованими кольорами обчислюються, і кожен із них перевіряється на контраст. Акцентний колір нижче й надалі застосовується поверх будь-якої з них.",
|
||||||
"Earlier": "Раніше",
|
"Earlier": "Раніше",
|
||||||
"Every folder": "Усі теки",
|
"Every folder": "Усі теки",
|
||||||
"Everyone addressed will receive this.": "Це отримають усі зазначені адресати.",
|
"Everyone addressed will receive this.": "Це отримають усі зазначені адресати.",
|
||||||
@@ -1206,6 +1345,14 @@ export const catalog: Catalog = {
|
|||||||
"Throw away your changes?": "Відкинути зміни?",
|
"Throw away your changes?": "Відкинути зміни?",
|
||||||
"Today, in your date format": "Сьогодні, у вашому форматі дати",
|
"Today, in your date format": "Сьогодні, у вашому форматі дати",
|
||||||
"Unread first": "Спочатку непрочитані",
|
"Unread first": "Спочатку непрочитані",
|
||||||
|
"Read first": "Спочатку прочитані",
|
||||||
|
"Unstarred first": "Спочатку непозначені",
|
||||||
|
"Smallest first": "Спочатку малі",
|
||||||
|
"Z to A": "Від Я до А",
|
||||||
|
"A to Z": "Від А до Я",
|
||||||
|
"It reads {shown} but goes to {actual}.": "Показано {shown}, але посилання веде на {actual}.",
|
||||||
|
"The full address is {href}.": "Повна адреса: {href}.",
|
||||||
|
"This message came from {domain}, which is outside your organisation.": "Цей лист надійшов з {domain} — за межами вашої організації.",
|
||||||
"Unsaved changes": "Незбережені зміни",
|
"Unsaved changes": "Незбережені зміни",
|
||||||
"View as": "Показувати як",
|
"View as": "Показувати як",
|
||||||
"Warnings": "Попередження",
|
"Warnings": "Попередження",
|
||||||
@@ -1251,8 +1398,119 @@ export const catalog: Catalog = {
|
|||||||
"This message was sent automatically, so no read receipt is offered.": "Цей лист надіслано автоматично, тому сповіщення про прочитання не пропонується.",
|
"This message was sent automatically, so no read receipt is offered.": "Цей лист надіслано автоматично, тому сповіщення про прочитання не пропонується.",
|
||||||
"This server will not hold a message longer than {span}.": "Цей сервер не утримує лист довше ніж {span}.",
|
"This server will not hold a message longer than {span}.": "Цей сервер не утримує лист довше ніж {span}.",
|
||||||
"Upload failed": "Не вдалося завантажити",
|
"Upload failed": "Не вдалося завантажити",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
"Every {n} {frequency}": "Кожні {n} {frequency}",
|
||||||
|
"Monthly": "Щомісяця",
|
||||||
|
"Monthly on day {days}": "Щомісяця {days}-го числа",
|
||||||
|
"Monthly on the {ordinal} {weekday}": "Щомісяця у {ordinal} {weekday}",
|
||||||
|
"Monthly on {weekday}": "Щомісяця у {weekday}",
|
||||||
|
"Weekly": "Щотижня",
|
||||||
|
"Weekly on {days}": "Щотижня у {days}",
|
||||||
|
"add {flag}": "додати {flag}",
|
||||||
|
"always": "завжди",
|
||||||
|
"body contains \"{value}\"": "текст містить \"{value}\"",
|
||||||
|
"body does not contain \"{value}\"": "текст не містить \"{value}\"",
|
||||||
|
"delete it": "видалити",
|
||||||
|
"fifth": "п’ятий",
|
||||||
|
"first": "перший",
|
||||||
|
"forward to {address}": "переслати на {address}",
|
||||||
|
"fourth": "четвертий",
|
||||||
|
"keep it": "залишити",
|
||||||
|
"last": "останній",
|
||||||
|
"mark it read": "позначити прочитаним",
|
||||||
|
"move to {folder}": "перемістити до {folder}",
|
||||||
|
"reject it": "відхилити",
|
||||||
|
"remove {flag}": "прибрати {flag}",
|
||||||
|
"second": "другий",
|
||||||
|
"size is over {n} KB": "розмір більший за {n} КБ",
|
||||||
|
"size is under {n} KB": "розмір менший за {n} КБ",
|
||||||
|
"star it": "позначити",
|
||||||
|
"stop": "зупинити",
|
||||||
|
"third": "третій",
|
||||||
|
"{header} address {op} \"{value}\"": "адреса {header} {op} \"{value}\"",
|
||||||
|
"{header} {op} \"{value}\"": "{header} {op} \"{value}\"",
|
||||||
|
"{rule}, until {date}": "{rule}, до {date}",
|
||||||
|
"{tests} → {actions}": "{tests} → {actions}",
|
||||||
|
// ── Third pass ──────────────────────────────────────────────────────
|
||||||
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
|
// to a scan for t("literal"). See #259.
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"This cannot be undone.": "Цю дію не можна скасувати.",
|
||||||
|
"Some could not be deleted: {error}": "Деякі не вдалося видалити: {error}",
|
||||||
|
"It was not deleted": "Контакт не було видалено",
|
||||||
|
"Empty address book": "Очистити цю адресну книгу",
|
||||||
|
"There is nothing in it to delete": "У ній немає чого видаляти",
|
||||||
|
"Empty “{name}”?": "Очистити «{name}»?",
|
||||||
|
"Delete them": "Видалити їх",
|
||||||
|
"Nothing was deleted": "Нічого не видалено",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "Сертифікат поширюється на",
|
||||||
|
"Details": "Подробиці",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Попередні листи з цієї адреси підписував {previous}. Цей підписаний {current}.",
|
||||||
|
"Fingerprint": "Відбиток",
|
||||||
|
"Hide details": "Сховати подробиці",
|
||||||
|
"Issued by": "Ким видано",
|
||||||
|
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Лист підписано OpenPGP, а ihasmail не може отримати відкритий ключ відправника.",
|
||||||
|
"It uses a signature algorithm ihasmail cannot check yet.": "Використано алгоритм підпису, який ihasmail поки не вміє перевіряти.",
|
||||||
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Підпис зроблено сертифікатом, що належить {name} і не покриває цю адресу.",
|
||||||
|
"Previous fingerprint": "Попередній відбиток",
|
||||||
|
"Signed at": "Підписано",
|
||||||
|
"Signed by {name} — the same signer as before.": "Підписано {name} — той самий підписувач, що й раніше.",
|
||||||
|
"Signed by {name}, seen here for the first time.": "Підписано {name}, трапляється тут уперше.",
|
||||||
|
"Signer": "Підписувач",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Це може бути оновлений сертифікат, а може бути й інша людина. Перш ніж довіряти, зв'яжіться з відправником іншим шляхом.",
|
||||||
|
"The certificate has expired.": "Термін дії сертифіката минув.",
|
||||||
|
"The certificate is not valid yet.": "Сертифікат ще не чинний.",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Лист не збігається з тим, що було підписано — його змінили після підписання або пошкодили в дорозі.",
|
||||||
|
"The signature carries no certificate that can be read.": "У підписі немає сертифіката, який вдалося б прочитати.",
|
||||||
|
"The signature could not be read.": "Підпис не вдалося прочитати.",
|
||||||
|
"The signature does not match the certificate sent with it.": "Підпис не відповідає надісланому з ним сертифікату.",
|
||||||
|
"The signature is not for this sender.": "Підпис не належить цьому відправникові.",
|
||||||
|
"The signed part is missing either the message or the signature.": "У підписаній частині бракує або листа, або підпису.",
|
||||||
|
"The signer has changed.": "Підписувач змінився.",
|
||||||
|
"This message is signed, and ihasmail could not check the signature.": "Цей лист підписано, і ihasmail не зміг перевірити підпис.",
|
||||||
|
"This signature does not check out.": "Цей підпис не сходиться.",
|
||||||
|
"Valid until": "Чинний до",
|
||||||
|
"a different certificate": "іншим сертифікатом",
|
||||||
|
"an unnamed signer": "неназваним підписувачем",
|
||||||
|
"as claimed by the signer": "за словами підписувача",
|
||||||
|
"first seen {date}": "уперше побачено {date}",
|
||||||
|
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail повідомить, якщо наступний лист із цієї адреси підпише хтось інший.",
|
||||||
|
"itself, or an issuer it does not name": "самим собою або неназваним видавцем",
|
||||||
|
"no address": "немає адреси",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
|
// ── Administration: domains ────────────────────────────────────
|
||||||
|
"{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 буде видалено. Скасувати це неможливо." },
|
||||||
|
"{n} domains": { one: "{n} домен", few: "{n} домени", many: "{n} доменів", other: "{n} домену" },
|
||||||
|
"{n} mailing lists": { one: "{n} список розсилки", few: "{n} списки розсилки", many: "{n} списків розсилки", other: "{n} списку розсилки" },
|
||||||
|
"{n} DKIM keys": { one: "{n} ключ DKIM", few: "{n} ключі DKIM", many: "{n} ключів DKIM", other: "{n} ключа DKIM" },
|
||||||
|
"{n} other items": { one: "{n} інший об'єкт", few: "{n} інші об'єкти", many: "{n} інших об'єктів", other: "{n} іншого об'єкта" },
|
||||||
|
// ── Administration ────────────────────────────────────────────────
|
||||||
|
"{n} accounts": { one: "{n} обліковий запис", few: "{n} облікові записи", many: "{n} облікових записів", other: "{n} облікового запису" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Delete {n} items": { one: "Видалити {n} об’єкт", few: "Видалити {n} об’єкти", many: "Видалити {n} об’єктів", other: "Видалити {n} об’єкта" },
|
||||||
|
"Delete {n} items?": { one: "Видалити {n} об’єкт?", few: "Видалити {n} об’єкти?", many: "Видалити {n} об’єктів?", other: "Видалити {n} об’єкта?" },
|
||||||
|
"Move {n} items": { one: "Перемістити {n} об’єкт", few: "Перемістити {n} об’єкти", many: "Перемістити {n} об’єктів", other: "Перемістити {n} об’єкта" },
|
||||||
|
"Move {n} items…": { one: "Перемістити {n} об’єкт…", few: "Перемістити {n} об’єкти…", many: "Перемістити {n} об’єктів…", other: "Перемістити {n} об’єкта…" },
|
||||||
|
"The event runs {n} days longer than this shows.": { one: "Подія триває на {n} день довше, ніж показано тут.", few: "Подія триває на {n} дні довше, ніж показано тут.", many: "Подія триває на {n} днів довше, ніж показано тут.", other: "Подія триває на {n} дня довше, ніж показано тут." },
|
||||||
|
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} гість не на цьому сервері, тому відомостей про зайнятість для нього немає.", few: "{n} гості не на цьому сервері, тому відомостей про зайнятість для них немає.", many: "{n} гостей не на цьому сервері, тому відомостей про зайнятість для них немає.", other: "{n} гостя не на цьому сервері, тому відомостей про зайнятість для них немає." },
|
||||||
|
"{n} items selected": { one: "Вибрано {n} об’єкт", few: "Вибрано {n} об’єкти", many: "Вибрано {n} об’єктів", other: "Вибрано {n} об’єкта" },
|
||||||
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
"Every {n} days": { one: "Щодня", few: "Кожні {n} дні", many: "Кожні {n} днів", other: "Кожні {n} дня" },
|
||||||
|
"Every {n} months": { one: "Щомісяця", few: "Кожні {n} місяці", many: "Кожні {n} місяців", other: "Кожні {n} місяця" },
|
||||||
|
"Every {n} months on day {days}": { one: "Щомісяця {days}-го числа", few: "Кожні {n} місяці {days}-го числа", many: "Кожні {n} місяців {days}-го числа", other: "Кожні {n} місяця {days}-го числа" },
|
||||||
|
"Every {n} months on the {ordinal} {weekday}": { one: "Щомісяця у {ordinal} {weekday}", few: "Кожні {n} місяці у {ordinal} {weekday}", many: "Кожні {n} місяців у {ordinal} {weekday}", other: "Кожні {n} місяця у {ordinal} {weekday}" },
|
||||||
|
"Every {n} months on {weekday}": { one: "Щомісяця у {weekday}", few: "Кожні {n} місяці у {weekday}", many: "Кожні {n} місяців у {weekday}", other: "Кожні {n} місяця у {weekday}" },
|
||||||
|
"Every {n} weeks": { one: "Щотижня", few: "Кожні {n} тижні", many: "Кожні {n} тижнів", other: "Кожні {n} тижня" },
|
||||||
|
"Every {n} weeks on {days}": { one: "Щотижня у {days}", few: "Кожні {n} тижні у {days}", many: "Кожні {n} тижнів у {days}", other: "Кожні {n} тижня у {days}" },
|
||||||
|
"Every {n} years": { one: "Щороку", few: "Кожні {n} роки", many: "Кожні {n} років", other: "Кожні {n} року" },
|
||||||
|
"{rule}, {n} times": { one: "{rule}, {n} раз", few: "{rule}, {n} рази", many: "{rule}, {n} разів", other: "{rule}, {n} раза" },
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
"Move {n} messages to Trash?": { one: "Перемістити {n} лист до кошика?", few: "Перемістити {n} листи до кошика?", many: "Перемістити {n} листів до кошика?", other: "Перемістити {n} листа до кошика?" },
|
"Move {n} messages to Trash?": { one: "Перемістити {n} лист до кошика?", few: "Перемістити {n} листи до кошика?", many: "Перемістити {n} листів до кошика?", other: "Перемістити {n} листа до кошика?" },
|
||||||
"{n} days": { one: "{n} день", few: "{n} дні", many: "{n} днів", other: "{n} дня" },
|
"{n} days": { one: "{n} день", few: "{n} дні", many: "{n} днів", other: "{n} дня" },
|
||||||
@@ -1264,8 +1522,7 @@ export const catalog: Catalog = {
|
|||||||
"Your administrator changed {n} settings": { one: "Адміністратор змінив {n} налаштування", few: "Адміністратор змінив {n} налаштування", many: "Адміністратор змінив {n} налаштувань", other: "Адміністратор змінив {n} налаштування" },
|
"Your administrator changed {n} settings": { one: "Адміністратор змінив {n} налаштування", few: "Адміністратор змінив {n} налаштування", many: "Адміністратор змінив {n} налаштувань", other: "Адміністратор змінив {n} налаштування" },
|
||||||
"Exported {n} events": { one: "Експортовано {n} подію", few: "Експортовано {n} події", many: "Експортовано {n} подій", other: "Експортовано {n} події" },
|
"Exported {n} events": { one: "Експортовано {n} подію", few: "Експортовано {n} події", many: "Експортовано {n} подій", other: "Експортовано {n} події" },
|
||||||
"Imported {n} events": { one: "Імпортовано {n} подію", few: "Імпортовано {n} події", many: "Імпортовано {n} подій", other: "Імпортовано {n} події" },
|
"Imported {n} events": { one: "Імпортовано {n} подію", few: "Імпортовано {n} події", many: "Імпортовано {n} подій", other: "Імпортовано {n} події" },
|
||||||
"Already here: {n} events, nothing imported": { one: "Уже є: {n} подія, нічого не імпортовано", few: "Уже є: {n} події, нічого не імпортовано", many: "Уже є: {n} подій, нічого не імпортовано", other: "Уже є: {n} події, нічого не імпортовано" },
|
"Updated {n} events, nothing new": { one: "Оновлено {n} подію, нових немає", few: "Оновлено {n} події, нових немає", many: "Оновлено {n} подій, нових немає", other: "Оновлено {n} події, нових немає" },
|
||||||
"{n} were already here": { one: "{n} уже була тут", few: "{n} уже були тут", many: "{n} уже були тут", other: "{n} уже були тут" },
|
|
||||||
/*
|
/*
|
||||||
* Ukrainian takes the same three forms as Russian and the same rule, but
|
* Ukrainian takes the same three forms as Russian and the same rule, but
|
||||||
* not the same words. Sharing a plural structure is not sharing a
|
* not the same words. Sharing a plural structure is not sharing a
|
||||||
@@ -1290,5 +1547,10 @@ export const catalog: Catalog = {
|
|||||||
"Marked {n} messages as read": { one: "{n} лист позначено як прочитаний", few: "{n} листи позначено як прочитані", many: "{n} листів позначено як прочитані", other: "{n} листа позначено як прочитані" },
|
"Marked {n} messages as read": { one: "{n} лист позначено як прочитаний", few: "{n} листи позначено як прочитані", many: "{n} листів позначено як прочитані", other: "{n} листа позначено як прочитані" },
|
||||||
"in {n} folders": { one: "у {n} теці", few: "у {n} теках", many: "у {n} теках", other: "у {n} теках" },
|
"in {n} folders": { one: "у {n} теці", few: "у {n} теках", many: "у {n} теках", other: "у {n} теках" },
|
||||||
"Deleted {n} messages": { one: "Видалено {n} лист", few: "Видалено {n} листи", many: "Видалено {n} листів", other: "Видалено {n} листа" },
|
"Deleted {n} messages": { one: "Видалено {n} лист", few: "Видалено {n} листи", many: "Видалено {n} листів", other: "Видалено {n} листа" },
|
||||||
|
// ── Emptying an address book, and deleting a selection (#277) ──
|
||||||
|
"Delete {n} contacts?": { one: "Видалити {n} контакт?", few: "Видалити {n} контакти?", many: "Видалити {n} контактів?", other: "Видалити {n} контакта?" },
|
||||||
|
"Deleted {n} contacts": { one: "Видалено {n} контакт", few: "Видалено {n} контакти", many: "Видалено {n} контактів", other: "Видалено {n} контакта" },
|
||||||
|
"{n} contacts will be deleted. This cannot be undone.": { one: "Буде видалено {n} контакт. Цю дію не можна скасувати.", few: "Буде видалено {n} контакти. Цю дію не можна скасувати.", many: "Буде видалено {n} контактів. Цю дію не можна скасувати.", other: "Буде видалено {n} контакта. Цю дію не можна скасувати." },
|
||||||
|
"{n} were also in other address books and were only removed from this one": { one: "{n} контакт також був в іншій адресній книзі й вилучений лише з цієї", few: "{n} контакти також були в інших адресних книгах і вилучені лише з цієї", many: "{n} контактів також були в інших адресних книгах і вилучені лише з цієї", other: "{n} контакта також були в інших адресних книгах і вилучені лише з цієї" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user