Compare commits

...
Author SHA1 Message Date
HampusandGitHub 34b6ecfbd2 chore(admin): remove the heap snapshot endpoint (#2814) 2026-09-16 18:56:01 +02:00
HampusandGitHub 4ef9c4c65b fix(api): restore commas in geoip location labels (#2812) 2026-09-16 18:27:50 +02:00
HampusandGitHub 3276039e41 feat(admin): audit admin reads and filter the log by access (#2811) 2026-09-16 17:23:03 +02:00
HampusandGitHub 03d1354562 chore(voice): remove voice reconciliation leftovers (#2810) 2026-09-16 17:09:57 +02:00
HampusandGitHub 964845d7a7 chore(voice): remove the recon service (#2808) 2026-09-16 16:53:30 +02:00
HampusandGitHub 3bc5dd8e0f fix(gateway): always clear expired custom statuses (#2807) 2026-09-16 16:52:22 +02:00
HampusandGitHub 17292fd6a5 fix(app): stop plain unicode symbols rendering as color emoji (#2806) 2026-09-16 16:33:13 +02:00
TarekandGitHub f753659899 feat(instance): make the status page URL configurable (#1159) 2026-09-16 15:20:37 +02:00
HampusandGitHub 7412ec3395 refactor(api): purge cache by canonical media prefix (#2802) 2026-09-16 02:32:36 +02:00
HampusandGitHub 570c8776c4 fix(api): require manage messages to remove others' reactions (#2799) 2026-09-15 18:16:55 +02:00
HampusandGitHub 910db6734b feat(experiments): ship seven treatments to everyone (#2798) 2026-09-15 18:03:35 +02:00
HampusandGitHub 9e614026d7 fix(api): stop exporting the change feed stats type (#2797) 2026-09-15 17:27:09 +02:00
HampusandGitHub b38e7c6433 feat(api): publish object storage changes to a JetStream feed (#2796) 2026-09-15 17:20:26 +02:00
HampusandGitHub 83c8e91955 fix(app): fit the user area popout shadow to its card (#2795) 2026-09-15 17:11:43 +02:00
HampusandGitHub 0532dd0440 fix(app): stop guild banner jumps and restore hover animation (#2792) 2026-09-15 09:31:52 +02:00
HampusandGitHub a08615e306 fix(gateway): match member search on username and global name (#2791) 2026-09-15 08:48:33 +02:00
HampusandGitHub f4c5fee17e feat(app): rank forward destinations and preview the message (#2790) 2026-09-15 07:23:26 +02:00
HampusandGitHub c5aaf65a10 fix(app): list friends with closed DMs in the forward modal (#2787) 2026-09-15 00:39:18 +02:00
HampusandGitHub 951e39da3d feat(api): add expression source guild routes (#2786) 2026-09-15 00:31:47 +02:00
HampusandGitHub b693d84d2b fix(openapi): restore named discriminated union branches (#2785) 2026-09-14 23:42:26 +02:00
HampusandGitHub 9bbf6c513b fix(installer): say what the email prompt is for (#2784) 2026-09-14 23:07:03 +02:00
HampusandGitHub 50cec92738 fix(api): batch member user lookups on guild load (#2783) 2026-09-14 23:06:36 +02:00
HampusandGitHub c212d315f4 fix(app): gate reworked typing indicators behind an experiment (#2782) 2026-09-14 21:54:52 +02:00
HampusandGitHub 1861432a53 fix(app): scope message rings and reach the composer by key (#2781) 2026-09-14 21:44:12 +02:00
HampusandGitHub d0c6146429 feat(app): gate a collapsing guild header behind an experiment (#2779) 2026-09-14 21:15:31 +02:00
HampusandGitHub 550e6b05a1 fix(app): show hover highlight and inset the focus ring (#2778) 2026-09-14 20:51:59 +02:00
HampusandGitHub 5b6949170f chore(donations): drop the donor email case backfill script (#2776) 2026-09-14 20:45:38 +02:00
HampusandGitHub f32bc37794 fix(guild): correct activity log sentence presentation (#2777) 2026-09-14 20:43:08 +02:00
HampusandGitHub 8ee2279b4b feat(donations): add Nordic currencies, raise amount ceilings (#2775) 2026-09-14 20:27:24 +02:00
HampusandGitHub 6339c3b8ad feat(app): gate the expression info card behind an experiment (#2773) 2026-09-14 20:14:58 +02:00
HampusandGitHub 7c9274847f feat(gateway): list bot ready guilds as unavailable (#2774) 2026-09-14 20:08:26 +02:00
HampusandGitHub 5d1dddc093 fix(deps): update rustls for RUSTSEC-2026-0285 (#2772) 2026-09-14 19:01:22 +02:00
HampusandGitHub 04481d7235 fix(app): keep blockquotes open across pasted lines (#2771) 2026-09-14 18:54:04 +02:00
Hampus Kraft a3d6cf37cb fix(guild): render activity log entries deterministically (#2766) 2026-09-14 17:39:19 +02:00
HampusandGitHub ed10f9d323 fix(app): gate blocked group rendering behind an experiment (#2763) 2026-09-14 16:08:19 +02:00
HampusandGitHub 4b278a0da8 fix(app): gate one-Tab message focus behind an experiment (#2762) 2026-09-14 15:55:00 +02:00
HampusandGitHub 1281045648 fix(app): gate single-source message hover behind an experiment (#2761) 2026-09-14 15:37:37 +02:00
HampusandGitHub 69c42cff90 docs: reword vague sentences and fix wrong claims (#2760) 2026-09-14 15:18:53 +02:00
HampusandGitHub 7d56481aba fix(app): copy selected message text without markdown (#2759) 2026-09-14 14:53:53 +02:00
HampusandGitHub 91a2604e9e fix(admin): apply audit logs and side effects to bulk actions (#2758) 2026-09-14 14:34:43 +02:00
HampusandGitHub a9dc74a520 fix(app): hide unread channels in muted collapsed categories (#2752) 2026-09-13 23:59:28 +02:00
HampusandGitHub a9cc04d277 fix(media-proxy): retry relay uploads on dropped connections (#2751) 2026-09-13 23:35:09 +02:00
HampusandGitHub 8cb097f954 fix(i18n): review translations and fix i18n library misuse (#2750) 2026-09-13 22:58:09 +02:00
HampusandGitHub 78f783f0c4 fix(media-proxy): retry dropped connections and log the cause (#2749) 2026-09-13 22:39:13 +02:00
Hampus Kraft d14998ff69 fix(app): back off image, reaction and settings retries on 429 (#2743) 2026-09-13 21:04:31 +02:00
HampusandGitHub ca7ddd9272 refactor(api): drop Bunny for pluggable cache purge adapters (#2742) 2026-09-13 20:28:56 +02:00
HampusandGitHub 84dcf6b8a8 refactor(imports): replace relative imports with path aliases (#2741) 2026-09-13 20:18:22 +02:00
HampusandGitHub a59b80ce11 docs(api): correct the synced preferences size limits (#2740) 2026-09-13 19:37:32 +02:00
HampusandGitHub 007f338823 feat(schema): add double tap reaction to synced preferences (#2739) 2026-09-13 18:47:21 +02:00
HampusandGitHub 7d34d25497 fix(ci): stop verifying published assets against the CDN (#2738) 2026-09-13 18:04:08 +02:00
HampusandGitHub 7375ac9d80 docs: simplify the reference and tighten the verifier (#2736) 2026-09-13 17:38:50 +02:00
HampusandGitHub 6af33c7188 refactor(svc): tidy the rust services and build tooling (#2735) 2026-09-13 17:38:32 +02:00
HampusandGitHub 33737e0f79 refactor(admin): tidy the admin routes and templates (#2734) 2026-09-13 17:38:15 +02:00
HampusandGitHub 5afbf67a70 refactor(api): tidy the api and shared packages (#2733) 2026-09-13 17:37:48 +02:00
HampusandGitHub 580401d2dc chore(marketing): remove the private marketing submodule (#2732) 2026-09-13 16:37:55 +02:00
HampusandGitHub 38b7c63431 fix(admin): declare gateway cluster_metrics in node stats (#2728) 2026-09-12 22:48:22 +02:00
HampusandGitHub 57d08cd091 fix(api): keep stickers on messages sent to personal notes (#2727) 2026-09-12 20:20:26 +02:00
HampusandGitHub 91e2d31614 style(voice): format the room_finished webhook test 2026-09-12 16:05:26 +02:00
HampusandGitHub d375dc7946 fix(ci): stop a new component blocking every other image promote 2026-09-12 16:01:47 +02:00
HampusandGitHub 3fffce2a4a fix(voice): correct the room_finished test harness types (#2723) 2026-09-12 15:49:47 +02:00
HampusandGitHub 376c509083 docs(self-host): tighten the env example comments (#2722) 2026-09-12 15:39:24 +02:00
HampusandGitHub 156315fd5a docs: drop exact counts that go stale when inventory changes (#2721) 2026-09-12 15:39:07 +02:00
HampusandGitHub c7b9e9b9bd fix(voice): stop room_finished evicting a whole channel (#2720) 2026-09-12 15:38:50 +02:00
HampusandGitHub 12397032e3 feat(voice): add the recon service and remove the old worker (#2719) 2026-09-12 15:38:32 +02:00
HampusandGitHub 4a285cbb11 fix(docs): drop the orphaned voice command footnote (#2718) 2026-09-12 01:45:27 +02:00
HampusandGitHub 6d600990fe fix(app): derive unread from an ack timestamp floor (#2717) 2026-09-12 01:40:36 +02:00
HampusandGitHub e1bc6c2f7e refactor(voice): remove the unused voice state ack (#2716) 2026-09-12 01:37:45 +02:00
HampusandGitHub 3e79530389 fix(app): defer non-critical gateway dispatches (#2715) 2026-09-12 01:36:04 +02:00
HampusandGitHub d0c84b3d9b fix(voice): apply noise suppression in the mic test (#2714) 2026-09-12 01:14:36 +02:00
3085 changed files with 317595 additions and 328124 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
"DOCKER_HOST": "unix:///var/run/docker.sock"
},
"runServices": ["workspace", "postgres", "valkey", "nats", "livekit", "meilisearch", "mailpit"],
"forwardPorts": [3000, 8088, 8080, 8771, 8082, 8773, 3010, 3020, 8333],
"forwardPorts": [3000, 8088, 8080, 8771, 8082, 8773, 3020, 8333],
"portsAttributes": {
"8088": {
"label": "Fluxer dev proxy",
-1
View File
@@ -256,7 +256,6 @@ services:
- "127.0.0.1:${FLUXER_DEV_GATEWAY_PORT:-8771}:8771"
- "127.0.0.1:${FLUXER_DEV_MEDIA_PROXY_PORT:-8082}:8082"
- "127.0.0.1:${FLUXER_DEV_APP_PROXY_PORT:-8773}:8773"
- "127.0.0.1:${FLUXER_DEV_MARKETING_PORT:-3010}:3010"
- "127.0.0.1:${FLUXER_DEV_ADMIN_PORT:-3020}:3020"
- "127.0.0.1:${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333"
depends_on:
-1
View File
@@ -9,7 +9,6 @@
**/.git/**
/.github/
/.pnpm-store/
/fluxer_marketing
**/.env
**/.env.*.local
-5
View File
@@ -1,7 +1,2 @@
/.github/CODEOWNERS @fluxerapp/developers
/.github/workflows/ @fluxerapp/developers
/fluxer_marketing @fluxerapp/developers
/.gitmodules @fluxerapp/developers
/.github/workflows/dispatch-private-marketing-build.yaml @fluxerapp/developers
/packages/i18n/marketing/ @fluxerapp/developers
/scripts/setup-private-marketing.sh @fluxerapp/developers
-18
View File
@@ -89,21 +89,3 @@ Submit translations through [Weblate](https://weblate.fluxer.tools), not through
All repository activity is governed by the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
## Private marketing project
The marketing implementation is maintained in a private repository at the `fluxer_marketing` submodule path. The public workspace, bootstrap, checks, and development stack work without initializing it.
Authorized maintainers can initialize only that submodule and install its independent dependencies:
```sh
./scripts/setup-private-marketing.sh
pnpm --dir fluxer_marketing install --frozen-lockfile
cargo metadata --locked --manifest-path fluxer_marketing/Cargo.toml
```
To run the private marketing service in the local development stack and direct application links to it, add this override to the ignored `config/env/local.env` file:
```sh
FLUXER_MARKETING_ENDPOINT=http://localhost:8088/marketing
```
-5
View File
@@ -22,11 +22,6 @@ f:docs:
f:gateway:
- changed-files:
- any-glob-to-any-file: fluxer_gateway/**/*
f:marketing:
- changed-files:
- any-glob-to-any-file:
- fluxer_marketing
- packages/i18n/marketing/**/*
f:media_proxy:
- changed-files:
- any-glob-to-any-file: fluxer_media_proxy/**/*
@@ -1,230 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: Dispatch private marketing build
on:
push:
branches:
- main
paths:
- fluxer_marketing
- Cargo.toml
- fluxer_common/**
- packages/fonts/manifest.json
- packages/fonts/NOTICE.md
- packages/fonts/LICENSE-IBM-PLEX.txt
- packages/fonts/css/locale-fallbacks.css
- packages/fonts/files/FluxerSans/**
- packages/fonts/files/FluxerMono/**
- packages/fonts/marketing/**
- packages/i18n/marketing/**
- fluxer_static/marketing/branding/**
- .github/workflows/dispatch-private-marketing-build.yaml
permissions:
actions: read
contents: read
concurrency:
group: private-marketing-dispatch
cancel-in-progress: false
jobs:
metadata:
name: resolve exact private build metadata
if: github.repository == 'fluxerapp/fluxer'
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
parent_sha: ${{ steps.inputs.outputs.parent_sha }}
gitlink_sha: ${{ steps.inputs.outputs.gitlink_sha }}
build_version: ${{ steps.inputs.outputs.build_version }}
correlation_id: ${{ steps.inputs.outputs.correlation_id }}
steps:
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Resolve trusted build inputs
id: inputs
env:
EVENT_AFTER: ${{ github.event.after }}
GH_TOKEN: ${{ steps.create-token.outputs.token }}
PARENT_SHA: ${{ github.sha }}
PUBLIC_REPOSITORY: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
run: |
set -euo pipefail
[[ "$GITHUB_EVENT_NAME" == "push" ]]
[[ "$GITHUB_REF" == "refs/heads/main" ]]
[[ "$PUBLIC_REPOSITORY" == "fluxerapp/fluxer" ]]
[[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]]
[[ "$EVENT_AFTER" == "$PARENT_SHA" ]]
[[ "$RUN_ID" =~ ^[1-9][0-9]*$ ]]
[[ "$RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]
(( 10#$RUN_ATTEMPT <= 10 ))
main_sha="$(gh api "repos/$PUBLIC_REPOSITORY/git/ref/heads/main" --jq .object.sha)"
[[ "$main_sha" =~ ^[0-9a-f]{40}$ ]]
main_comparison="$(gh api "repos/$PUBLIC_REPOSITORY/compare/$PARENT_SHA...$main_sha")"
main_status="$(jq -r .status <<<"$main_comparison")"
[[ "$main_status" == "identical" || "$main_status" == "ahead" ]]
[[ "$(jq -r .merge_base_commit.sha <<<"$main_comparison")" == "$PARENT_SHA" ]]
commit="$(gh api "repos/$PUBLIC_REPOSITORY/git/commits/$PARENT_SHA")"
[[ "$(jq -r .sha <<<"$commit")" == "$PARENT_SHA" ]]
tree_sha="$(jq -r .tree.sha <<<"$commit")"
[[ "$tree_sha" =~ ^[0-9a-f]{40}$ ]]
entry="$(
gh api "repos/$PUBLIC_REPOSITORY/git/trees/$tree_sha" |
jq -cer '[.tree[] | select(.path == "fluxer_marketing")] | if length == 1 then .[0] else error("expected exactly one marketing gitlink") end'
)"
mode="$(jq -r .mode <<<"$entry")"
type="$(jq -r .type <<<"$entry")"
gitlink_sha="$(jq -r .sha <<<"$entry")"
path="$(jq -r .path <<<"$entry")"
if [[ "$mode" != "160000" || "$type" != "commit" || "$path" != "fluxer_marketing" || ! "$gitlink_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Public parent does not contain a valid fluxer_marketing gitlink."
exit 1
fi
run="$(gh api "repos/$PUBLIC_REPOSITORY/actions/runs/$RUN_ID")"
[[ "$(jq -r .id <<<"$run")" == "$RUN_ID" ]]
[[ "$(jq -r .run_attempt <<<"$run")" == "$RUN_ATTEMPT" ]]
[[ "$(jq -r .event <<<"$run")" == "push" ]]
[[ "$(jq -r .head_sha <<<"$run")" == "$PARENT_SHA" ]]
run_created_at="$(jq -r .created_at <<<"$run")"
[[ "$run_created_at" =~ ^[1-9][0-9]{3}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
run_created_epoch="$(date -u -d "$run_created_at" +%s)"
[[ "$run_created_epoch" =~ ^[1-9][0-9]*$ ]]
build_epoch=$((run_created_epoch + 10#$RUN_ATTEMPT - 1))
read -r year month day time_segment <<<"$(date -u -d "@$build_epoch" '+%Y %m %d %H%M%S')"
month="$((10#$month))"
micro="$((10#$time_segment))"
build_version="$year.$month$day.$micro"
[[ "$build_version" =~ ^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.([0-9]|[1-9][0-9]{0,5})$ ]]
correlation_id="public-${RUN_ID}-${RUN_ATTEMPT}"
[[ "$correlation_id" =~ ^[A-Za-z0-9._:-]{1,64}$ ]]
{
echo "parent_sha=$PARENT_SHA"
echo "gitlink_sha=$gitlink_sha"
echo "build_version=$build_version"
echo "correlation_id=$correlation_id"
} >>"$GITHUB_OUTPUT"
dispatch:
name: dispatch exact private build
needs: metadata
runs-on: ubuntu-24.04
timeout-minutes: 65
environment: private-marketing-dispatch
permissions: {}
steps:
- name: Validate trusted build inputs
env:
DISPATCH_ENABLED: ${{ vars.MARKETING_DISPATCH_ENABLED }}
EXPECTED_PARENT_SHA: ${{ github.sha }}
EXPECTED_CORRELATION_ID: public-${{ github.run_id }}-${{ github.run_attempt }}
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
run: |
set -euo pipefail
[[ "$GITHUB_EVENT_NAME" == "push" ]]
[[ "$GITHUB_REF" == "refs/heads/main" ]]
[[ "$GITHUB_REPOSITORY" == "fluxerapp/fluxer" ]]
[[ "$PARENT_SHA" == "$EXPECTED_PARENT_SHA" ]]
[[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]]
[[ "$GITLINK_SHA" =~ ^[0-9a-f]{40}$ ]]
[[ "$BUILD_VERSION" =~ ^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.([0-9]|[1-9][0-9]{0,5})$ ]]
[[ "$CORRELATION_ID" == "$EXPECTED_CORRELATION_ID" ]]
[[ "$CORRELATION_ID" =~ ^[A-Za-z0-9._:-]{1,64}$ ]]
if [[ "$DISPATCH_ENABLED" != "true" ]]; then
echo "::error::Private marketing dispatch is intentionally disabled until the package cutover guard completes."
exit 1
fi
- name: Create private dispatch token
id: private-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: marketing
permission-actions: write
- name: Dispatch exact private build
env:
GH_TOKEN: ${{ steps.private-token.outputs.token }}
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
run: |
set -euo pipefail
gh api --method POST repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/dispatches \
--field ref=main \
--field "inputs[parent_sha]=$PARENT_SHA" \
--field "inputs[gitlink_sha]=$GITLINK_SHA" \
--field "inputs[build_version]=$BUILD_VERSION" \
--field "inputs[correlation_id]=$CORRELATION_ID"
- name: Wait for private build conclusion
env:
GH_TOKEN: ${{ steps.private-token.outputs.token }}
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
run: |
set -euo pipefail
expected_title="marketing-build correlation=$CORRELATION_ID parent=$PARENT_SHA gitlink=$GITLINK_SHA version=$BUILD_VERSION"
deadline=$((SECONDS + 3600))
run_id=""
while (( SECONDS < deadline )); do
runs="$(gh api "repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/runs?event=workflow_dispatch&per_page=100" --jq '[.workflow_runs[] | {id, event, display_title, status, conclusion}]')"
matches="$(jq --arg title "$expected_title" '[.[] | select(.event == "workflow_dispatch" and .display_title == $title)]' <<<"$runs")"
count="$(jq 'length' <<<"$matches")"
if [[ "$count" == "1" ]]; then
run_id="$(jq -r '.[0].id' <<<"$matches")"
break
fi
if [[ "$count" != "0" ]]; then
echo "::error::Private build correlation matched multiple workflow runs."
exit 1
fi
sleep 10
done
if [[ -z "$run_id" ]]; then
echo "::error::Timed out waiting for the private build dispatch to appear."
exit 1
fi
while (( SECONDS < deadline )); do
runs="$(gh api "repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/runs?event=workflow_dispatch&per_page=100" --jq '[.workflow_runs[] | {id, event, display_title, status, conclusion}]')"
matches="$(jq --arg title "$expected_title" '[.[] | select(.event == "workflow_dispatch" and .display_title == $title)]' <<<"$runs")"
if [[ "$(jq 'length' <<<"$matches")" != "1" || "$(jq -r '.[0].id' <<<"$matches")" != "$run_id" ]]; then
echo "::error::Private build correlation is missing or ambiguous."
exit 1
fi
run="$(jq '.[0]' <<<"$matches")"
status="$(jq -r '.status' <<<"$run")"
conclusion="$(jq -r '.conclusion // empty' <<<"$run")"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" != "success" ]]; then
echo "::error::Private marketing build concluded with $conclusion."
exit 1
fi
echo "Private marketing build completed successfully."
exit 0
fi
sleep 15
done
echo "::error::Timed out waiting for the private marketing build."
exit 1
+2 -2
View File
@@ -77,14 +77,14 @@ jobs:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
run: |
set -euo pipefail
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/i18n/marketing packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
echo "No generated catalog changes."
exit 0
fi
git config user.name "fluxer-ci[bot]"
git config user.email "${{ vars.FLUXER_CI_APP_USER_ID }}+fluxer-ci[bot]@users.noreply.github.com"
git add fluxer_app/src/features/i18n/locales packages/i18n/marketing packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
git add fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
git commit -m "i18n: compile Weblate catalogs"
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git push origin "HEAD:$WEBLATE_BRANCH"
-4
View File
@@ -1,4 +0,0 @@
[submodule "fluxer_marketing"]
path = fluxer_marketing
url = https://github.com/fluxerapp/marketing.git
update = none
Generated
+17 -13
View File
@@ -266,9 +266,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.16.3"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -276,14 +276,15 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.40.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -1622,7 +1623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -1815,6 +1816,7 @@ dependencies = [
"http 1.4.2",
"http-body 1.0.1",
"http-body-util",
"hyper",
"libc",
"moka",
"parking_lot",
@@ -1992,6 +1994,7 @@ dependencies = [
"axum",
"base64",
"fluxer_common",
"futures-util",
"hex",
"rand 0.10.1",
"reqwest",
@@ -2020,6 +2023,7 @@ dependencies = [
"reqwest",
"serde_json",
"sha2 0.11.0",
"tempfile",
"thiserror",
"time",
"tracing",
@@ -3966,14 +3970,14 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
name = "rustls"
version = "0.23.40"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
@@ -4025,7 +4029,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -4036,9 +4040,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
@@ -4649,7 +4653,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -5517,7 +5521,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
-1
View File
@@ -17,7 +17,6 @@ members = [
"packages/markdown_parser/rust",
]
exclude = [
"fluxer_marketing",
"packages/markdown_parser/rust/fuzz",
]
resolver = "2"
-5
View File
@@ -68,7 +68,6 @@ FLUXER_LIVEKIT_DEFAULT_REGION={"id":"local","name":"Local","emoji":"LC","latitud
FLUXER_API_PORT=8080
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
FLUXER_API_WORKER_MODE=all_lanes
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION=true
FLUXER_APP_DEV_PORT=3000
FLUXER_APP_PROXY_PORT=8773
FLUXER_STATIC_DIR=fluxer_app/dist
@@ -91,10 +90,6 @@ FLUXER_ADMIN_BASE_PATH=/admin
FLUXER_ADMIN_SECRET_KEY_BASE=dev-admin-secret-key-base
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=dev-admin-oauth-secret
FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback
FLUXER_MARKETING_PORT=3010
FLUXER_MARKETING_HOST=0.0.0.0
FLUXER_MARKETING_BASE_PATH=/marketing
FLUXER_MARKETING_SECRET_KEY_BASE=dev-marketing-secret-key-base
FLUXER_SUDO_MODE_SECRET=dev-sudo-secret
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
+25 -31
View File
@@ -1,26 +1,21 @@
# Every variable docker-compose.yml reads from this file is named here:
# uncommented when it has no default, commented with its default when it has one.
# A name absent from this file is one Compose does not forward, and it reaches a
# service only through a Compose override file that adds it to that service's
# environment. packages/config/src/__tests__/DeployEnvCoverage.test.ts fails when
# a Compose edit forgets the matching line here. Compose expands this file from
# top to bottom, so a line written with ${...} has to sit below every name it
# reads.
# Every variable docker-compose.yml reads is named here, uncommented when it has
# no default and commented with its default when it has one. A name absent from
# this file reaches a service only through a Compose override. Compose expands
# top to bottom, so a line using ${...} must sit below every name it reads.
FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443
# The three lines above are the address browsers use, and every endpoint the
# services advertise carries the port from FLUXER_PUBLIC_PORT. They do not move
# what the host publishes. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT further down
# do that, and a non-default port needs the matching one set as well. Both
# complete recipes are written out beside them.
# The lines above are the address browsers use, and every advertised endpoint
# carries FLUXER_PUBLIC_PORT. They do not move what the host publishes.
# FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT below do that, and a non-default port
# needs the matching one set as well. Complete recipes sit beside them.
# How browsers reach this instance.
#
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate.
# Point DNS at this host and there is nothing else to configure.
# Point DNS at this host.
#
# Behind your own reverse proxy (nginx, Traefik, HAProxy, Cloudflare Tunnel,
# another Caddy): uncomment COMPOSE_FILE below. Fluxer then serves plain HTTP on
@@ -65,13 +60,12 @@ FLUXER_PUBLIC_PORT=443
# hostname means automatic HTTPS on 443 whatever FLUXER_PUBLIC_SCHEME says.
#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com
# The old name for the value above. It is read only when
# FLUXER_EDGE_SITE_ADDRESS is unset, so an existing .env keeps the listener
# it already had. Rename it to FLUXER_EDGE_SITE_ADDRESS at your convenience.
# The old name for the value above, read only when FLUXER_EDGE_SITE_ADDRESS is
# unset, so an existing .env keeps the listener it already had.
#FLUXER_CADDY_SITE_ADDRESS=
# Host side of the edge's publishes, and the only two names that decide which
# host ports Fluxer binds. The container side is fixed. Container 80 carries the
# Host side of the edge's publishes, and the only names that decide which host
# ports Fluxer binds. The container side is fixed. Container 80 carries the
# HTTP to HTTPS redirect and the Let's Encrypt HTTP challenge under an https
# scheme, and the site itself under an http one. Container 443 carries the TLS
# site. FLUXER_HTTPS_PORT moves the TCP and the UDP publish together, because
@@ -251,7 +245,7 @@ LIVEKIT_API_SECRET=CHANGE_ME
# LiveKit finds the address browsers dial by asking a STUN server. A host that
# cannot reach one over UDP stops with "could not resolve external IP", and the
# address is then set by hand: put it in FLUXER_LIVEKIT_NODE_IP and set
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the two STUN entries at another
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the STUN entries at another
# server to keep the lookup and leave Google out of it.
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
@@ -279,11 +273,11 @@ FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
FLUXER_DISCOVERY_ENABLED=true
# Container memory. The 25 limits sum to 18.25 GiB, which is a sum of ceilings and
# Container memory. The limits sum to 18.25 GiB, which is a sum of ceilings and
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB.
# The four reservations are cgroup memory.low, which biases the kernel away from
# reclaiming from the services whose death takes the whole instance down. They do
# not reserve anything. Lower the limits on a smaller host.
# The reservations are cgroup memory.low, which biases the kernel away from
# reclaiming from services whose death takes the instance down. They reserve
# nothing. Lower the limits on a smaller host.
#FLUXER_CADDY_MEMORY_LIMIT=256mb
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
@@ -364,16 +358,16 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_ERLANG_SCHEDULERS_MIN=2
#FLUXER_ERLANG_SCHEDULERS_MAX=16
# In-flight request ceiling for the four services Compose forwards it to: the
# users and messages routers and their shards. Leave it unset and each service
# uses its own built-in default, which is what the numbers below describe. Set it
# and the one value replaces the built-in default on all four, so size it for the
# busiest of them rather than for the smallest. The built-in defaults are 192 for
# In-flight request ceiling for the services Compose forwards it to: the users
# and messages routers and their shards. Leave it unset and each service uses its
# built-in default. Set it and the one value replaces that default on all of
# them, so size it for the busiest. The built-in defaults are 192 for
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
# Compose does not forward this to. A router holds a slot for the whole round
# trip to its shard, so this is a ceiling on requests in flight at once and not a
# rate: too low a value does not slow requests down, it rejects them, and the api
# turns that rejection into a 503.
# rate: too low a value does not slow requests down, it rejects them. The api
# turns that rejection into a 503 and logs "shard rejected the request because
# it is at its concurrency limit".
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
# The api and the Rust services name their fixed Postgres statement shapes so the
-1
View File
@@ -413,7 +413,6 @@ services:
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}
FLUXER_API_WORKER_MODE: all_lanes
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION: "true"
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
healthcheck:
test: ["CMD", "node", "-e", "const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}"]
+306
View File
@@ -38,9 +38,16 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
let mut spec: openapiv3::OpenAPI =
serde_json::from_str(&json_str).expect("failed to parse openapi-admin.json");
adapt_progenitor_throttled_errors(&mut spec);
relax_guild_audit_log_schemas(&mut spec);
relax_progenitor_schema_strictness(&mut spec);
let mut settings = progenitor::GenerationSettings::new();
settings.with_interface(progenitor::InterfaceStyle::Positional);
settings.with_inner_type(
"reqwest::header::HeaderMap"
.parse()
.expect("valid generated client header type"),
);
let mut generator = progenitor::Generator::new(&settings);
let tokens = generator
@@ -136,6 +143,305 @@ fn adapt_progenitor_throttled_errors(spec: &mut openapiv3::OpenAPI) {
}
}
fn relax_guild_audit_log_schemas(spec: &mut openapiv3::OpenAPI) {
let components = spec.components.as_mut().expect("missing API components");
let entry = object_schema_mut(components, "GuildAuditLogEntryResponse");
entry.additional_properties = None;
let openapiv3::ReferenceOr::Item(options) = entry
.properties
.get_mut("options")
.expect("GuildAuditLogEntryResponse has no options property")
else {
panic!("GuildAuditLogEntryResponse options must be an inline schema");
};
let openapiv3::SchemaKind::Type(openapiv3::Type::Object(options)) = &mut options.schema_kind
else {
panic!("GuildAuditLogEntryResponse options must be an object schema");
};
options.additional_properties = None;
let change = object_schema_mut(components, "AuditLogChangeSchema");
change.additional_properties = None;
for property in ["old_value", "new_value"] {
change.properties.insert(
property.to_string(),
openapiv3::ReferenceOr::Item(Box::new(openapiv3::Schema {
schema_data: openapiv3::SchemaData::default(),
schema_kind: openapiv3::SchemaKind::Any(openapiv3::AnySchema::default()),
})),
);
}
}
fn object_schema_mut<'a>(
components: &'a mut openapiv3::Components,
name: &str,
) -> &'a mut openapiv3::ObjectType {
let Some(openapiv3::ReferenceOr::Item(schema)) = components.schemas.get_mut(name) else {
panic!("missing inline {name} schema");
};
let openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) = &mut schema.schema_kind
else {
panic!("{name} must be an object schema");
};
object
}
const MAX_SCHEMA_REFERENCE_DEPTH: usize = 32;
fn relax_progenitor_schema_strictness(spec: &mut openapiv3::OpenAPI) {
let registry = spec.components.clone().unwrap_or_default();
if let Some(components) = spec.components.as_mut() {
for schema in components.schemas.values_mut() {
relax_schema_reference(schema, &registry);
}
for response in components.responses.values_mut() {
if let openapiv3::ReferenceOr::Item(response) = response {
relax_response(response, &registry);
}
}
for parameter in components.parameters.values_mut() {
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
relax_parameter(parameter, &registry);
}
}
for request_body in components.request_bodies.values_mut() {
if let openapiv3::ReferenceOr::Item(request_body) = request_body {
relax_content(&mut request_body.content, &registry);
}
}
for header in components.headers.values_mut() {
if let openapiv3::ReferenceOr::Item(header) = header {
relax_parameter_format(&mut header.format, &registry);
}
}
}
for path in spec.paths.paths.values_mut() {
let openapiv3::ReferenceOr::Item(path) = path else {
continue;
};
for parameter in &mut path.parameters {
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
relax_parameter(parameter, &registry);
}
}
for operation in [
&mut path.get,
&mut path.put,
&mut path.post,
&mut path.delete,
&mut path.options,
&mut path.head,
&mut path.patch,
&mut path.trace,
]
.into_iter()
.flatten()
{
for parameter in &mut operation.parameters {
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
relax_parameter(parameter, &registry);
}
}
if let Some(openapiv3::ReferenceOr::Item(request_body)) =
operation.request_body.as_mut()
{
relax_content(&mut request_body.content, &registry);
}
for response in operation
.responses
.responses
.values_mut()
.chain(operation.responses.default.iter_mut())
{
if let openapiv3::ReferenceOr::Item(response) = response {
relax_response(response, &registry);
}
}
}
}
}
fn relax_response(response: &mut openapiv3::Response, registry: &openapiv3::Components) {
relax_content(&mut response.content, registry);
for header in response.headers.values_mut() {
if let openapiv3::ReferenceOr::Item(header) = header {
relax_parameter_format(&mut header.format, registry);
}
}
}
fn relax_content(content: &mut openapiv3::Content, registry: &openapiv3::Components) {
for media_type in content.values_mut() {
if let Some(schema) = media_type.schema.as_mut() {
relax_schema_reference(schema, registry);
}
}
}
fn relax_parameter(parameter: &mut openapiv3::Parameter, registry: &openapiv3::Components) {
let format = match parameter {
openapiv3::Parameter::Query { parameter_data, .. }
| openapiv3::Parameter::Header { parameter_data, .. }
| openapiv3::Parameter::Path { parameter_data, .. }
| openapiv3::Parameter::Cookie { parameter_data, .. } => &mut parameter_data.format,
};
relax_parameter_format(format, registry);
}
fn relax_parameter_format(
format: &mut openapiv3::ParameterSchemaOrContent,
registry: &openapiv3::Components,
) {
match format {
openapiv3::ParameterSchemaOrContent::Schema(schema) => {
relax_schema_reference(schema, registry)
}
openapiv3::ParameterSchemaOrContent::Content(content) => relax_content(content, registry),
}
}
fn relax_schema_reference(
schema: &mut openapiv3::ReferenceOr<openapiv3::Schema>,
registry: &openapiv3::Components,
) {
if let openapiv3::ReferenceOr::Item(schema) = schema {
relax_schema(schema, registry);
}
}
fn relax_boxed_schema_reference(
schema: &mut openapiv3::ReferenceOr<Box<openapiv3::Schema>>,
registry: &openapiv3::Components,
) {
if let openapiv3::ReferenceOr::Item(schema) = schema {
relax_schema(schema, registry);
}
}
fn relax_schema(schema: &mut openapiv3::Schema, registry: &openapiv3::Components) {
if flattens_objects_beside_scalars(&schema.schema_kind, registry) {
schema.schema_kind = openapiv3::SchemaKind::Any(openapiv3::AnySchema::default());
return;
}
match &mut schema.schema_kind {
openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
relax_additional_properties(&mut object.additional_properties, registry);
for property in object.properties.values_mut() {
relax_boxed_schema_reference(property, registry);
}
}
openapiv3::SchemaKind::Type(openapiv3::Type::Array(array)) => {
if let Some(items) = array.items.as_mut() {
relax_boxed_schema_reference(items, registry);
}
}
openapiv3::SchemaKind::Type(_) => {}
openapiv3::SchemaKind::OneOf { one_of: subschemas }
| openapiv3::SchemaKind::AllOf { all_of: subschemas }
| openapiv3::SchemaKind::AnyOf { any_of: subschemas } => {
for subschema in subschemas {
relax_schema_reference(subschema, registry);
}
}
openapiv3::SchemaKind::Not { not } => relax_schema_reference(not, registry),
openapiv3::SchemaKind::Any(any) => {
relax_additional_properties(&mut any.additional_properties, registry);
for property in any.properties.values_mut() {
relax_boxed_schema_reference(property, registry);
}
if let Some(items) = any.items.as_mut() {
relax_boxed_schema_reference(items, registry);
}
for subschema in any
.one_of
.iter_mut()
.chain(any.all_of.iter_mut())
.chain(any.any_of.iter_mut())
{
relax_schema_reference(subschema, registry);
}
if let Some(not) = any.not.as_mut() {
relax_schema_reference(not, registry);
}
}
}
}
fn relax_additional_properties(
additional_properties: &mut Option<openapiv3::AdditionalProperties>,
registry: &openapiv3::Components,
) {
match additional_properties {
Some(openapiv3::AdditionalProperties::Any(false)) => *additional_properties = None,
Some(openapiv3::AdditionalProperties::Schema(schema)) => {
relax_schema_reference(schema, registry)
}
_ => {}
}
}
fn flattens_objects_beside_scalars(
schema_kind: &openapiv3::SchemaKind,
registry: &openapiv3::Components,
) -> bool {
let subschemas = match schema_kind {
openapiv3::SchemaKind::OneOf { one_of } => one_of,
openapiv3::SchemaKind::AnyOf { any_of } => any_of,
_ => return false,
};
let mut objects = false;
let mut scalars = false;
for subschema in subschemas {
if resolves_to_object(subschema, registry, MAX_SCHEMA_REFERENCE_DEPTH) {
objects = true;
} else {
scalars = true;
}
}
objects && scalars
}
fn resolves_to_object(
schema: &openapiv3::ReferenceOr<openapiv3::Schema>,
registry: &openapiv3::Components,
depth: usize,
) -> bool {
let Some(depth) = depth.checked_sub(1) else {
return false;
};
let schema = match schema {
openapiv3::ReferenceOr::Reference { reference } => {
let Some(target) = reference
.strip_prefix("#/components/schemas/")
.and_then(|name| registry.schemas.get(name))
else {
return false;
};
return resolves_to_object(target, registry, depth);
}
openapiv3::ReferenceOr::Item(schema) => schema,
};
match &schema.schema_kind {
openapiv3::SchemaKind::Type(openapiv3::Type::Object(_)) => true,
openapiv3::SchemaKind::Type(_) => false,
openapiv3::SchemaKind::OneOf { one_of: subschemas }
| openapiv3::SchemaKind::AllOf { all_of: subschemas }
| openapiv3::SchemaKind::AnyOf { any_of: subschemas } => subschemas
.iter()
.any(|subschema| resolves_to_object(subschema, registry, depth)),
openapiv3::SchemaKind::Not { .. } => false,
openapiv3::SchemaKind::Any(any) => {
any.typ.as_deref() == Some("object")
|| !any.properties.is_empty()
|| any.additional_properties.is_some()
}
}
}
struct Face {
css_family: String,
weight: u64,
+91 -71
View File
@@ -933,6 +933,22 @@
},
"description": "Filter by target entity ID (user, channel, role, invite code, etc.)"
},
{
"name": "access",
"in": "query",
"required": false,
"schema": {
"description": "Only return entries recorded by reads or only entries recorded by writes",
"x-enumNames": ["read", "write"],
"x-enumDescriptions": [
"An entry recorded by an operation that only reads data",
"An entry recorded by an operation that changes data or triggers work"
],
"enum": ["read", "write"],
"type": "string"
},
"description": "Only return entries recorded by reads or only entries recorded by writes"
},
{
"name": "sort_by",
"in": "query",
@@ -1667,7 +1683,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Enqueue one background administrative job. The `task` discriminator selects both the body variant and the ACL evaluated for the request: `update_user_flags` needs bulk:update:user_flags, `update_suspicious_activity_flags` needs bulk:update:suspicious_activity, `update_guild_features` needs bulk:update:guild_features, `add_guild_members` needs bulk:add:guild_members, `schedule_user_deletion` needs bulk:delete:users, and `delete_user_messages` needs bulk:delete:user_messages. Returns a job_id immediately; observe progress at /admin/jobs/:job_id. Note: the schedule_user_deletion worker skips Stripe refunds, session termination, and identifier banning — apply those separately for high-risk accounts.",
"description": "Enqueue one background administrative job. The `task` discriminator selects both the body variant and the ACL evaluated for the request: `update_user_flags` needs bulk:update:user_flags, `update_suspicious_activity_flags` needs bulk:update:suspicious_activity, `update_guild_features` needs bulk:update:guild_features, `add_guild_members` needs bulk:add:guild_members, `schedule_user_deletion` needs bulk:delete:users, and `delete_user_messages` needs bulk:delete:user_messages. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.",
"security": [{"adminApiKey": []}],
"requestBody": {
"required": true,
@@ -5427,59 +5443,6 @@
}
}
},
"/admin/system/heap-snapshots": {
"post": {
"operationId": "create_admin_system_heap_snapshot",
"summary": "Create a V8 heap snapshot",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/octet-stream": {"schema": {"$ref": "#/components/schemas/HeapSnapshotResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Writes a V8 heap snapshot of the current process and returns the snapshot file. Used for diagnosing memory leaks. Requires SYSTEM_HEAP_SNAPSHOT permission.",
"security": [{"adminApiKey": []}]
}
},
"/admin/users": {
"get": {
"operationId": "list_admin_users",
@@ -9874,7 +9837,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions to assign"
@@ -9904,7 +9867,6 @@
"required": ["users", "total"],
"additionalProperties": false
},
"HeapSnapshotResponse": {"type": "string", "format": "binary", "description": "V8 heap snapshot file"},
"SendSystemDmRequest": {
"type": "object",
"properties": {
@@ -10671,7 +10633,9 @@
"logo_url": {"nullable": true, "type": "string"},
"wordmark_url": {"nullable": true, "type": "string"},
"favicon_url": {"nullable": true, "type": "string"},
"theme_color": {"nullable": true, "type": "string"}
"theme_color": {"nullable": true, "type": "string"},
"status_page_url": {"nullable": true, "type": "string"},
"status_page_incident_history_url": {"nullable": true, "type": "string"}
},
"required": [
"product_name",
@@ -10680,7 +10644,9 @@
"logo_url",
"wordmark_url",
"favicon_url",
"theme_color"
"theme_color",
"status_page_url",
"status_page_incident_history_url"
],
"additionalProperties": false
},
@@ -11144,7 +11110,9 @@
"logo_url": {"nullable": true, "type": "string", "maxLength": 2048},
"wordmark_url": {"nullable": true, "type": "string", "maxLength": 2048},
"favicon_url": {"nullable": true, "type": "string", "maxLength": 2048},
"theme_color": {"nullable": true, "type": "string", "maxLength": 64}
"theme_color": {"nullable": true, "type": "string", "maxLength": 64},
"status_page_url": {"nullable": true, "type": "string", "maxLength": 2048},
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048}
}
},
"setup": {"nullable": true, "type": "object", "properties": {"configured": {"type": "boolean"}}},
@@ -11859,7 +11827,39 @@
},
"process_count": {"$ref": "#/components/schemas/Int32Type"},
"process_limit": {"$ref": "#/components/schemas/Int32Type"},
"uptime_seconds": {"$ref": "#/components/schemas/Int32Type"}
"uptime_seconds": {"$ref": "#/components/schemas/Int32Type"},
"cluster_metrics": {
"type": "object",
"properties": {
"gateway_cluster_member_count": {"$ref": "#/components/schemas/Int32Type"},
"gateway_cluster_discovery_resolve_failures_total": {"$ref": "#/components/schemas/Int32Type"},
"gateway_cluster_membership_transitions_total": {
"type": "object",
"properties": {
"up": {"$ref": "#/components/schemas/Int32Type"},
"down": {"$ref": "#/components/schemas/Int32Type"}
},
"required": ["up", "down"],
"additionalProperties": false
},
"gateway_node_router_owner_resolutions_total": {
"type": "object",
"properties": {
"self": {"$ref": "#/components/schemas/Int32Type"},
"peer": {"$ref": "#/components/schemas/Int32Type"}
},
"required": ["self", "peer"],
"additionalProperties": false
}
},
"required": [
"gateway_cluster_member_count",
"gateway_cluster_discovery_resolve_failures_total",
"gateway_cluster_membership_transitions_total",
"gateway_node_router_owner_resolutions_total"
],
"additionalProperties": false
}
},
"required": [
"node_id",
@@ -12589,6 +12589,16 @@
}
},
"action": {"type": "string", "minLength": 1, "maxLength": 256},
"access": {
"description": "Whether the recorded operation read data or changed it",
"x-enumNames": ["read", "write"],
"x-enumDescriptions": [
"An entry recorded by an operation that only reads data",
"An entry recorded by an operation that changes data or triggers work"
],
"enum": ["read", "write"],
"type": "string"
},
"audit_log_reason": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4000},
"metadata": {"type": "object", "additionalProperties": {"type": "string", "maxLength": 4000}},
"created_at": {"type": "string"}
@@ -12606,6 +12616,7 @@
"related_guilds",
"related_channels",
"action",
"access",
"audit_log_reason",
"metadata",
"created_at"
@@ -12694,7 +12705,7 @@
},
"acls": {
"description": "Replacement list of access control permissions for the key",
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"}
}
@@ -12712,7 +12723,7 @@
"type": "string"
},
"acls": {
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12742,7 +12753,7 @@
"maximum": 365
},
"acls": {
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions for the key"
@@ -12763,7 +12774,7 @@
"type": "string"
},
"acls": {
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12776,7 +12787,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 64},
"description": "Every admin access control permission the admin API recognises"
@@ -12867,7 +12878,6 @@
"report:view",
"report:view:reporter_pii",
"system_dm:send",
"system:heap_snapshot",
"user:cancel:bulk_message_deletion",
"user:delete",
"user:disable:suspicious",
@@ -14885,7 +14895,10 @@
"nullable": true,
"type": "string"
},
"reason": {"description": "The reason provided for the action", "type": "string"},
"reason": {
"description": "The audit log reason. For bans and timeouts without an X-Audit-Log-Reason header this is the reason sent in the request body",
"type": "string"
},
"options": {
"description": "Additional options depending on action type",
"type": "object",
@@ -14893,15 +14906,22 @@
"channel_id": {"description": "Channel ID for relevant actions", "type": "string"},
"count": {"description": "Count of items affected", "type": "number"},
"delete_member_days": {
"description": "Number of days of messages to delete on member ban",
"description": "Deprecated. Whole days of messages deleted, written only by bans recorded before delete_message_seconds",
"type": "string"
},
"delete_message_seconds": {
"description": "Seconds of the banned user's messages that the ban deleted, present only when positive",
"type": "number"
},
"id": {"description": "ID of the affected entity", "type": "string"},
"integration_type": {"description": "Type of integration", "type": "number"},
"message_id": {"description": "Message ID for relevant actions", "type": "string"},
"members_removed": {"description": "Number of members removed", "type": "number"},
"role_name": {"description": "Name of the role", "type": "string"},
"type": {"description": "Type identifier", "type": "number"},
"role_name": {"description": "Name of the role when the entry was written", "type": "string"},
"type": {
"description": "Channel type for CHANNEL_CREATE, CHANNEL_UPDATE and CHANNEL_DELETE. Overwrite target type (0 role, 1 member) for CHANNEL_OVERWRITE_*",
"type": "number"
},
"inviter_id": {"description": "ID of the user who created the invite", "type": "string"},
"max_age": {"description": "Maximum age of the invite in seconds", "type": "number"},
"max_uses": {"description": "Maximum number of uses for the invite", "type": "number"},
@@ -15396,7 +15416,7 @@
"pending_bulk_message_deletion_at": {"nullable": true, "type": "string"},
"deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
"deletion_public_reason": {"nullable": true, "type": "string"},
"acls": {"maxItems": 112, "type": "array", "items": {"type": "string"}},
"acls": {"maxItems": 111, "type": "array", "items": {"type": "string"}},
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
"has_totp": {"type": "boolean"},
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
-2
View File
@@ -79,7 +79,6 @@ pub const REPORT_RESOLVE: &str = "report:resolve";
pub const REPORT_VIEW: &str = "report:view";
pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii";
pub const SYSTEM_DM_SEND: &str = "system_dm:send";
pub const SYSTEM_HEAP_SNAPSHOT: &str = "system:heap_snapshot";
pub const USER_CANCEL_BULK_MESSAGE_DELETION: &str = "user:cancel:bulk_message_deletion";
pub const USER_DELETE: &str = "user:delete";
pub const USER_DISABLE_SUSPICIOUS: &str = "user:disable:suspicious";
@@ -192,7 +191,6 @@ pub const ALL_ACLS: &[&str] = &[
REPORT_VIEW,
REPORT_VIEW_REPORTER_PII,
SYSTEM_DM_SEND,
SYSTEM_HEAP_SNAPSHOT,
USER_CANCEL_BULK_MESSAGE_DELETION,
USER_DELETE,
USER_DISABLE_SUSPICIOUS,
+42 -1
View File
@@ -10,10 +10,11 @@ pub struct SearchAuditLogsParams {
pub admin_user_id: Option<String>,
pub target_id: Option<String>,
pub target_type: Option<String>,
pub access: Option<String>,
pub sort_by: Option<String>,
pub sort_order: Option<String>,
pub limit: u32,
pub offset: u32,
pub offset: u64,
}
impl AdminApiClient {
@@ -21,6 +22,12 @@ impl AdminApiClient {
&self,
params: &SearchAuditLogsParams,
) -> ApiResult<AuditLogsListResponse> {
let access = params
.access
.as_deref()
.map(audit_access)
.transpose()?
.map(|value| value.to_string());
let sort_by = params
.sort_by
.as_deref()
@@ -46,6 +53,7 @@ impl AdminApiClient {
params.target_type.as_deref().unwrap_or_default(),
),
("target_id", params.target_id.as_deref().unwrap_or_default()),
("access", access.as_deref().unwrap_or_default()),
("sort_by", sort_by.as_deref().unwrap_or_default()),
("sort_order", sort_order.as_deref().unwrap_or_default()),
("limit", limit.as_str()),
@@ -55,6 +63,11 @@ impl AdminApiClient {
}
}
fn audit_access(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsAccess> {
generated_types::ListAdminAuditLogsAccess::try_from(value)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
let value = match value {
"created_at" => "createdAt",
@@ -83,6 +96,13 @@ mod tests {
assert_eq!(audit_sort_order("desc").unwrap().to_string(), "desc");
}
#[test]
fn accepts_only_known_access_filters() {
assert_eq!(audit_access("read").unwrap().to_string(), "read");
assert_eq!(audit_access("write").unwrap().to_string(), "write");
assert!(audit_access("all").is_err());
}
#[test]
fn rejects_lossy_audit_totals() {
for total in [serde_json::json!(1.5), serde_json::json!(-1)] {
@@ -99,6 +119,7 @@ mod tests {
"admin_user_id": "234567890123456789",
"admin_user": null,
"action": "USER_UPDATE",
"access": "write",
"target_id": "345678901234567890",
"target_type": "user",
"target_user": null,
@@ -118,6 +139,26 @@ mod tests {
assert_eq!(generated.logs[0].action.to_string(), "USER_UPDATE");
let response: AuditLogsListResponse = serde_json::from_value(json.clone()).unwrap();
assert_eq!(response.logs[0].access.as_deref(), Some("write"));
assert_eq!(serde_json::to_value(response).unwrap(), json);
}
#[test]
fn deserializes_audit_entries_from_an_api_without_access() {
let json = serde_json::json!({
"logs": [{
"log_id": "123456789012345678",
"admin_user_id": "234567890123456789",
"action": "USER_UPDATE",
"target_id": "345678901234567890",
"target_type": "user",
"audit_log_reason": null,
"metadata": {},
"created_at": "2026-09-11T12:00:00.000Z"
}],
"total": 1
});
let response: AuditLogsListResponse = serde_json::from_value(json).unwrap();
assert_eq!(response.logs[0].access, None);
}
}
+104 -59
View File
@@ -6,7 +6,7 @@ use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
impl AdminApiClient {
pub async fn ban_email(&self, email: &str) -> ApiResult<()> {
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"email",
generated_types::AdminBlocklistEntryCreateRequest {
@@ -15,50 +15,63 @@ impl AdminApiClient {
}),
..Default::default()
},
audit_log_reason,
)
.await
}
pub async fn unban_email(&self, email: &str) -> ApiResult<()> {
self.delete_blocklist_entry("email", email, None).await
pub async fn unban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.delete_blocklist_entry("email", email, None, audit_log_reason)
.await
}
pub async fn check_email_ban(&self, email: &str) -> ApiResult<BanCheckResult> {
self.check_blocklist_entry("email", email, None).await
}
pub async fn ban_ip(&self, ip: &str) -> ApiResult<()> {
pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"ip",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_0: Some(generated_types::BanIpRequest { ip: ip.to_owned() }),
..Default::default()
},
audit_log_reason,
)
.await
}
pub async fn unban_ip(&self, ip: &str) -> ApiResult<()> {
self.delete_blocklist_entry("ip", ip, None).await
pub async fn unban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.delete_blocklist_entry("ip", ip, None, audit_log_reason)
.await
}
pub async fn check_ip_ban(&self, ip: &str) -> ApiResult<BanCheckResult> {
self.check_blocklist_entry("ip", ip, None).await
}
pub async fn add_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
pub async fn add_suspicious_email_domain(
&self,
domain: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry(
SUSPICIOUS_EMAIL_DOMAIN_LIST,
generated_types::AdminBlocklistEntryCreateRequest {
subtype_2: Some(suspicious_email_domain_request(domain)?),
..Default::default()
},
audit_log_reason,
)
.await
}
pub async fn remove_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None)
pub async fn remove_suspicious_email_domain(
&self,
domain: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None, audit_log_reason)
.await
}
@@ -67,7 +80,7 @@ impl AdminApiClient {
.await
}
pub async fn ban_phrase(&self, phrase: &str) -> ApiResult<()> {
pub async fn ban_phrase(&self, phrase: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"phrase",
generated_types::AdminBlocklistEntryCreateRequest {
@@ -76,19 +89,25 @@ impl AdminApiClient {
}),
..Default::default()
},
audit_log_reason,
)
.await
}
pub async fn unban_phrase(&self, phrase: &str) -> ApiResult<()> {
self.delete_blocklist_entry("phrase", phrase, None).await
pub async fn unban_phrase(
&self,
phrase: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry("phrase", phrase, None, audit_log_reason)
.await
}
pub async fn check_phrase_ban(&self, phrase: &str) -> ApiResult<BanCheckResult> {
self.check_blocklist_entry("phrase", phrase, None).await
}
pub async fn ban_url(&self, url: &str) -> ApiResult<()> {
pub async fn ban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"url",
generated_types::AdminBlocklistEntryCreateRequest {
@@ -101,19 +120,26 @@ impl AdminApiClient {
}),
..Default::default()
},
audit_log_reason,
)
.await
}
pub async fn unban_url(&self, url: &str) -> ApiResult<()> {
self.delete_blocklist_entry("url", url, None).await
pub async fn unban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.delete_blocklist_entry("url", url, None, audit_log_reason)
.await
}
pub async fn check_url_ban(&self, url: &str) -> ApiResult<BanCheckResult> {
self.check_blocklist_entry("url", url, None).await
}
pub async fn ban_url_domain(&self, domain: &str, match_subdomains: bool) -> ApiResult<()> {
pub async fn ban_url_domain(
&self,
domain: &str,
match_subdomains: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry(
"url-domain",
generated_types::AdminBlocklistEntryCreateRequest {
@@ -127,12 +153,17 @@ impl AdminApiClient {
}),
..Default::default()
},
audit_log_reason,
)
.await
}
pub async fn unban_url_domain(&self, domain: &str) -> ApiResult<()> {
self.delete_blocklist_entry("url-domain", domain, None)
pub async fn unban_url_domain(
&self,
domain: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry("url-domain", domain, None, audit_log_reason)
.await
}
@@ -145,20 +176,19 @@ impl AdminApiClient {
sha256_hex: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::AdminBlocklistEntryCreateRequest {
subtype_6: Some(generated_types::BanFileShaRequest {
category: None,
content_type: None,
notes: None,
severity: None,
sha256_hex: sha256_hex.to_owned(),
source_url: None,
}),
..Default::default()
};
self.post_void_with_reason(
"/admin/blocklists/file-sha/entries",
Some(&serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?),
self.create_blocklist_entry(
"file-sha",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_6: Some(generated_types::BanFileShaRequest {
category: None,
content_type: None,
notes: None,
severity: None,
sha256_hex: sha256_hex.to_owned(),
source_url: None,
}),
..Default::default()
},
audit_log_reason,
)
.await
@@ -169,12 +199,8 @@ impl AdminApiClient {
sha256_hex: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_void_with_reason(
&blocklist_entry_path("file-sha", sha256_hex),
None,
audit_log_reason,
)
.await
self.delete_blocklist_entry("file-sha", sha256_hex, None, audit_log_reason)
.await
}
pub async fn check_file_sha_ban(&self, sha256_hex: &str) -> ApiResult<BanCheckResult> {
@@ -198,7 +224,11 @@ impl AdminApiClient {
.await
}
pub async fn ban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
pub async fn ban_avatar_hash(
&self,
hash_short: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry(
"avatar-hash",
generated_types::AdminBlocklistEntryCreateRequest {
@@ -212,12 +242,17 @@ impl AdminApiClient {
}),
..Default::default()
},
audit_log_reason,
)
.await
}
pub async fn unban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
self.delete_blocklist_entry("avatar-hash", hash_short, None)
pub async fn unban_avatar_hash(
&self,
hash_short: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry("avatar-hash", hash_short, None, audit_log_reason)
.await
}
@@ -236,20 +271,36 @@ impl AdminApiClient {
self.generated_value(response.into_inner())
}
pub async fn ban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
pub async fn ban_profile_substring(
&self,
scope: &str,
substring: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry(
PROFILE_SUBSTRING_LIST,
generated_types::AdminBlocklistEntryCreateRequest {
subtype_8: Some(profile_substring_request(scope, substring)?),
..Default::default()
},
audit_log_reason,
)
.await
}
pub async fn unban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
self.delete_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope))
.await
pub async fn unban_profile_substring(
&self,
scope: &str,
substring: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry(
PROFILE_SUBSTRING_LIST,
substring,
Some(scope),
audit_log_reason,
)
.await
}
pub async fn check_profile_substring_ban(
@@ -265,13 +316,14 @@ impl AdminApiClient {
&self,
list_type: &str,
body: generated_types::AdminBlocklistEntryCreateRequest,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let list_type = blocklist_list_type(list_type)?;
self.generated()
self.generated_with_reason(audit_log_reason)?
.create_admin_blocklist_entry(list_type, &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
.map(drop)
.map_err(|error| self.generated_error(error))
}
async fn delete_blocklist_entry(
@@ -279,14 +331,15 @@ impl AdminApiClient {
list_type: &str,
entry_value: &str,
scope: Option<&str>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let list_type = blocklist_list_type(list_type)?;
let scope = scope.map(blocklist_delete_scope).transpose()?;
self.generated()
self.generated_with_reason(audit_log_reason)?
.delete_admin_blocklist_entry(list_type, entry_value, scope)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
.map(drop)
.map_err(|error| self.generated_error(error))
}
async fn check_blocklist_entry(
@@ -315,14 +368,6 @@ fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlock
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn blocklist_entry_path(list_type: &str, entry_value: &str) -> String {
format!(
"/admin/blocklists/{}/entries/{}",
urlencoding::encode(list_type),
urlencoding::encode(entry_value)
)
}
fn blocklist_get_scope(scope: &str) -> ApiResult<generated_types::GetAdminBlocklistEntryScope> {
generated_types::GetAdminBlocklistEntryScope::try_from(scope)
.map_err(|e| ApiError::Parse(e.to_string()))
+104 -89
View File
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::GeneratedClient;
use crate::{config::AdminConfig, session::Session};
use progenitor_client::ClientInfo;
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue};
use reqwest::{Method, RequestBuilder};
use serde::Serialize;
@@ -34,46 +36,39 @@ impl<T> ApiResultExt<T> for ApiResult<T> {
}
pub struct AdminApiClient {
http_client: reqwest::Client,
generated: crate::api::generated::GeneratedClient,
base_url: String,
access_token: String,
proxy_client_ip_headers: HeaderMap,
generated: GeneratedClient,
}
impl AdminApiClient {
pub fn new(http_client: &reqwest::Client, config: &AdminConfig, session: &Session) -> Self {
let generated_http_client = build_generated_http_client(config, session);
let generated = crate::api::generated::GeneratedClient::new_with_client(
let generated = GeneratedClient::new_with_client(
&config.api_endpoint,
generated_http_client,
http_client.clone(),
build_session_headers(config, session),
);
Self {
http_client: http_client.clone(),
generated,
base_url: config.api_endpoint.clone(),
access_token: session.access_token.clone(),
proxy_client_ip_headers: build_proxy_client_ip_headers(config),
}
Self { generated }
}
fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String {
let base = format!("{}{}", self.base_url, path);
match query_params {
None => base,
Some(params) => {
let filtered: Vec<_> = params.iter().filter(|(_, v)| !v.is_empty()).collect();
if filtered.is_empty() {
return base;
}
let query = filtered
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
format!("{base}?{query}")
}
let mut url = format!("{}{}", self.generated.baseurl(), path);
let query = query_params
.unwrap_or_default()
.iter()
.filter(|(_, value)| !value.is_empty())
.map(|(key, value)| {
format!(
"{}={}",
urlencoding::encode(key),
urlencoding::encode(value)
)
})
.collect::<Vec<_>>()
.join("&");
if !query.is_empty() {
url.push('?');
url.push_str(&query);
}
url
}
fn request(
@@ -81,30 +76,26 @@ impl AdminApiClient {
method: Method,
path: &str,
query_params: Option<&[(&str, &str)]>,
) -> RequestBuilder {
let url = self.build_url(path, query_params);
self.http_client
.request(method, &url)
.header("Authorization", format!("Bearer {}", self.access_token))
.header("Content-Type", "application/json")
.headers(self.proxy_client_ip_headers.clone())
}
fn with_audit_log_reason(
builder: RequestBuilder,
audit_log_reason: Option<&str>,
) -> RequestBuilder {
match audit_log_reason {
Some(reason) => builder.header("X-Audit-Log-Reason", reason),
None => builder,
}
) -> ApiResult<RequestBuilder> {
let url = self.build_url(path, query_params);
Ok(self
.generated
.client()
.request(method, &url)
.header("Content-Type", "application/json")
.headers(self.headers_with_reason(audit_log_reason)?))
}
fn with_json_body(builder: RequestBuilder, body: Option<&serde_json::Value>) -> RequestBuilder {
match body {
Some(body) => builder.json(body),
None => builder,
fn headers_with_reason(&self, audit_log_reason: Option<&str>) -> ApiResult<HeaderMap> {
let mut headers = self.generated.inner().clone();
if let Some(reason) = audit_log_reason {
let mut value = HeaderValue::from_str(reason)
.map_err(|_| ApiError::Parse("invalid audit log reason header".to_owned()))?;
value.set_sensitive(true);
headers.insert("x-audit-log-reason", value);
}
Ok(headers)
}
async fn send_request(builder: RequestBuilder) -> ApiResult<reqwest::Response> {
@@ -114,12 +105,28 @@ impl AdminApiClient {
.map_err(|e| ApiError::Network(e.to_string()))
}
async fn send_json<B: Serialize + ?Sized>(
&self,
method: Method,
path: &str,
body: Option<&B>,
audit_log_reason: Option<&str>,
) -> ApiResult<reqwest::Response> {
let builder = self.request(method, path, None, audit_log_reason)?;
let builder = match body {
Some(body) => builder.json(body),
None => builder,
};
Self::send_request(builder).await
}
pub async fn get<T: DeserializeOwned>(
&self,
path: &str,
query_params: Option<&[(&str, &str)]>,
) -> ApiResult<T> {
let response = Self::send_request(self.request(Method::GET, path, query_params)).await?;
let response =
Self::send_request(self.request(Method::GET, path, query_params, None)?).await?;
Self::parse_response(response).await
}
@@ -149,9 +156,9 @@ impl AdminApiClient {
T: DeserializeOwned,
B: Serialize + ?Sized,
{
let builder =
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
let response = Self::send_request(builder.json(body)).await?;
let response = self
.send_json(Method::POST, path, Some(body), audit_log_reason)
.await?;
Self::parse_response(response).await
}
@@ -161,9 +168,9 @@ impl AdminApiClient {
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let builder =
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
let response = self
.send_json(Method::POST, path, body, audit_log_reason)
.await?;
Self::parse_response(response).await
}
@@ -177,9 +184,9 @@ impl AdminApiClient {
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let builder =
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
let response = self
.send_json(Method::POST, path, body, audit_log_reason)
.await?;
Self::parse_void_response(response).await
}
@@ -197,9 +204,9 @@ impl AdminApiClient {
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let builder =
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
let response = self
.send_json(Method::PATCH, path, body, audit_log_reason)
.await?;
Self::parse_response(response).await
}
@@ -213,9 +220,9 @@ impl AdminApiClient {
T: DeserializeOwned,
B: Serialize + ?Sized,
{
let builder =
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
let response = Self::send_request(builder.json(body)).await?;
let response = self
.send_json(Method::PATCH, path, Some(body), audit_log_reason)
.await?;
Self::parse_response(response).await
}
@@ -225,9 +232,9 @@ impl AdminApiClient {
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let builder =
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
let response = self
.send_json(Method::PUT, path, body, audit_log_reason)
.await?;
Self::parse_response(response).await
}
@@ -241,9 +248,9 @@ impl AdminApiClient {
T: DeserializeOwned,
B: Serialize + ?Sized,
{
let builder =
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
let response = Self::send_request(builder.json(body)).await?;
let response = self
.send_json(Method::PUT, path, Some(body), audit_log_reason)
.await?;
Self::parse_response(response).await
}
@@ -253,9 +260,9 @@ impl AdminApiClient {
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let builder =
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
let response = self
.send_json(Method::PUT, path, body, audit_log_reason)
.await?;
Self::parse_void_response(response).await
}
@@ -269,9 +276,9 @@ impl AdminApiClient {
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let builder =
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
let response = self
.send_json(Method::DELETE, path, body, audit_log_reason)
.await?;
Self::parse_void_response(response).await
}
@@ -281,9 +288,9 @@ impl AdminApiClient {
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let builder =
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
let response = self
.send_json(Method::DELETE, path, body, audit_log_reason)
.await?;
Self::parse_response(response).await
}
@@ -302,10 +309,21 @@ impl AdminApiClient {
Err(ApiError::Http { status, message })
}
pub(crate) fn generated(&self) -> &crate::api::generated::GeneratedClient {
pub(crate) fn generated(&self) -> &GeneratedClient {
&self.generated
}
pub(crate) fn generated_with_reason(
&self,
audit_log_reason: Option<&str>,
) -> ApiResult<GeneratedClient> {
Ok(GeneratedClient::new_with_client(
self.generated.baseurl(),
self.generated.client().clone(),
self.headers_with_reason(audit_log_reason)?,
))
}
pub(crate) fn generated_value<T, U>(&self, value: U) -> ApiResult<T>
where
T: DeserializeOwned,
@@ -346,17 +364,14 @@ impl AdminApiClient {
}
}
fn build_generated_http_client(config: &AdminConfig, session: &Session) -> reqwest::Client {
fn build_session_headers(config: &AdminConfig, session: &Session) -> HeaderMap {
let mut headers = HeaderMap::new();
let auth_value = HeaderValue::from_str(&format!("Bearer {}", session.access_token))
let mut auth_value = HeaderValue::from_str(&format!("Bearer {}", session.access_token))
.expect("failed to build generated API Authorization header");
auth_value.set_sensitive(true);
headers.insert(AUTHORIZATION, auth_value);
headers.extend(build_proxy_client_ip_headers(config));
reqwest::Client::builder()
.user_agent(format!("FluxerAdmin/{} (Rust)", config.build_version))
.default_headers(headers)
.build()
.expect("failed to create generated API HTTP client")
headers
}
pub(crate) fn with_proxy_client_ip_header(
+2 -3
View File
@@ -9,7 +9,7 @@ impl AdminApiClient {
pub async fn generate_gift_codes(
&self,
count: u32,
duration_type: &str,
duration_type: generated_types::GiftCodeDurationTypeSchema,
duration_quantity: u32,
) -> ApiResult<CodesResponse> {
let body = generated_types::GenerateGiftCodesRequest {
@@ -22,8 +22,7 @@ impl AdminApiClient {
)
.map_err(ApiError::Parse)?
.into(),
duration_type: generated_types::GiftCodeDurationTypeSchema::try_from(duration_type)
.map_err(|e| ApiError::Parse(e.to_string()))?,
duration_type,
};
let response = self
.generated()
+17
View File
@@ -1,5 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use progenitor_client::{ClientHooks, ClientInfo, Error, OperationInfo};
use reqwest::header::HeaderMap;
#[allow(
clippy::all,
unused_imports,
@@ -16,6 +19,20 @@ pub use inner::types;
pub use inner::Client as GeneratedClient;
impl ClientHooks<HeaderMap> for GeneratedClient {
async fn pre<E>(
&self,
request: &mut reqwest::Request,
_info: &OperationInfo,
) -> Result<(), Error<E>> {
let headers = request.headers_mut();
for (name, value) in self.inner() {
headers.entry(name.clone()).or_insert_with(|| value.clone());
}
Ok(())
}
}
pub(crate) fn snowflake(value: &str) -> types::SnowflakeType {
types::SnowflakeType::Variant0(value.to_owned())
}
+3 -3
View File
@@ -16,7 +16,7 @@ impl AdminApiClient {
&self,
query: &str,
limit: u32,
offset: u32,
offset: u64,
) -> ApiResult<SearchGuildsResponse> {
let limit = limit.to_string();
let offset = offset.to_string();
@@ -97,7 +97,7 @@ impl AdminApiClient {
&self,
guild_id: &str,
limit: u32,
offset: u32,
offset: u64,
) -> ApiResult<ListGuildMembersResponse> {
let limit = limit.to_string();
let offset = offset.to_string();
@@ -252,7 +252,7 @@ impl AdminApiClient {
&self,
guild_id: &str,
limit: u32,
offset: u32,
offset: u64,
) -> ApiResult<SearchReportsResponse> {
self.search_reports(&SearchReportsParams {
reported_guild_id: Some(guild_id),
+4 -5
View File
@@ -24,9 +24,9 @@ impl AdminApiClient {
let max_lookback_days = params.max_lookback_days.to_string();
let query_params = [
("limit", limit.as_str()),
("cursor_bucket_day", cursor_bucket_day.as_str()),
("cursor_created_at", cursor_created_at.as_str()),
("cursor_job_id", cursor_job_id.as_str()),
("cursor_bucket_day", cursor_bucket_day),
("cursor_created_at", cursor_created_at),
("cursor_job_id", cursor_job_id),
("max_lookback_days", max_lookback_days.as_str()),
("status", params.status.as_deref().unwrap_or_default()),
("task_type", params.task_type.as_deref().unwrap_or_default()),
@@ -70,10 +70,9 @@ impl AdminApiClient {
}
}
fn cursor_field(cursor: Option<&serde_json::Value>, field: &str) -> String {
fn cursor_field<'a>(cursor: Option<&'a serde_json::Value>, field: &str) -> &'a str {
cursor
.and_then(|cursor| cursor.get(field))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_owned()
}
+3 -3
View File
@@ -22,7 +22,7 @@ pub struct SearchReportsParams<'a> {
pub sort_by: Option<&'a str>,
pub sort_order: Option<&'a str>,
pub limit: u32,
pub offset: u32,
pub offset: u64,
}
impl AdminApiClient {
@@ -129,7 +129,7 @@ impl AdminApiClient {
&self,
reporter_id: &str,
limit: u32,
offset: u32,
offset: u64,
) -> ApiResult<SearchReportsResponse> {
self.search_reports(&SearchReportsParams {
reporter_id: Some(reporter_id),
@@ -144,7 +144,7 @@ impl AdminApiClient {
&self,
reported_user_id: &str,
limit: u32,
offset: u32,
offset: u64,
) -> ApiResult<SearchReportsResponse> {
self.search_reports(&SearchReportsParams {
reported_user_id: Some(reported_user_id),
+2
View File
@@ -9,6 +9,8 @@ pub struct AuditLogEntry {
#[serde(default)]
pub admin_user: Option<AuditLogUserSummary>,
pub action: String,
#[serde(default)]
pub access: Option<String>,
pub target_id: String,
pub target_type: String,
#[serde(default)]
+11 -3
View File
@@ -6,8 +6,14 @@ pub fn deserialize_discriminator<'de, D: Deserializer<'de>>(d: D) -> Result<Stri
let v: serde_json::Value = Deserialize::deserialize(d)?;
match v {
serde_json::Value::String(s) => Ok(format!("{:0>4}", s)),
serde_json::Value::Number(n) => Ok(format!("{:04}", n.as_u64().unwrap_or(0))),
_ => Ok("0000".to_owned()),
serde_json::Value::Number(n) => n
.as_u64()
.map(|value| format!("{value:04}"))
.ok_or_else(|| serde::de::Error::custom("expected an unsigned integer discriminator")),
serde_json::Value::Null => Ok("0000".to_owned()),
_ => Err(serde::de::Error::custom(
"expected string or unsigned integer discriminator",
)),
}
}
@@ -15,7 +21,9 @@ pub fn deserialize_string_or_u64<'de, D: Deserializer<'de>>(d: D) -> Result<u64,
let v: serde_json::Value = Deserialize::deserialize(d)?;
match v {
serde_json::Value::String(s) => s.parse::<u64>().map_err(serde::de::Error::custom),
serde_json::Value::Number(n) => Ok(n.as_u64().unwrap_or(0)),
serde_json::Value::Number(n) => n
.as_u64()
.ok_or_else(|| serde::de::Error::custom("expected an unsigned 64-bit integer")),
serde_json::Value::Null => Ok(0),
_ => Err(serde::de::Error::custom("expected string or number")),
}
@@ -326,6 +326,8 @@ pub struct AppBrandingConfigResponse {
pub wordmark_url: Option<String>,
pub favicon_url: Option<String>,
pub theme_color: Option<String>,
pub status_page_url: Option<String>,
pub status_page_incident_history_url: Option<String>,
}
impl Default for AppBrandingConfigResponse {
@@ -338,6 +340,8 @@ impl Default for AppBrandingConfigResponse {
wordmark_url: None,
favicon_url: None,
theme_color: None,
status_page_url: None,
status_page_incident_history_url: None,
}
}
}
@@ -858,6 +862,10 @@ pub struct AppBrandingConfigUpdateRequest {
pub favicon_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme_color: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_page_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_page_incident_history_url: Option<Option<String>>,
}
#[derive(Clone, Debug, Default, Serialize)]
+6 -4
View File
@@ -15,7 +15,7 @@ impl AdminApiClient {
email: Option<&str>,
last_active_ip: Option<&str>,
limit: u32,
offset: u32,
offset: u64,
) -> ApiResult<SearchUsersResponse> {
let limit = limit.to_string();
let offset = offset.to_string();
@@ -35,7 +35,8 @@ impl AdminApiClient {
let response = response.into_inner();
Ok(SearchUsersResponse {
users: self.generated_value(response.users)?,
total: response.total as u64,
total: crate::api::generated::number_to_u64(response.total, "total")
.map_err(ApiError::Parse)?,
})
}
@@ -426,6 +427,7 @@ impl AdminApiClient {
reason_code: i32,
public_reason: Option<&str>,
days_until_deletion: u32,
audit_log_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserDeletionScheduleRequest {
days_until_deletion: crate::api::generated::nonzero_u32(
@@ -439,10 +441,10 @@ impl AdminApiClient {
.map_err(ApiError::Parse)?,
};
let response = self
.generated()
.generated_with_reason(audit_log_reason)?
.schedule_admin_user_deletion(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
.map_err(|error| self.generated_error(error))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
+30 -21
View File
@@ -81,7 +81,7 @@ async fn admin_api_keys_post(
"create" => {
let name = form.clean("name").unwrap_or_default();
let acls = form.list_values_any(&["acls[]", "acls"]);
return match client.create_api_key(&name, &acls).await {
match client.create_api_key(&name, &acls).await {
Ok(created) => {
let keys = client
.list_api_keys()
@@ -107,29 +107,38 @@ async fn admin_api_keys_post(
is_htmx,
)
}
};
}
"revoke" => {
if let Some(key_id) = form.clean("key_id") {
let result = client.revoke_api_key(&key_id).await;
let flash = match result.log_error("revoke API key") {
Some(_) => FlashData::success("API key revoked."),
None => FlashData::error("Failed to revoke API key"),
};
return flash::redirect_with_flash(
&format!("{base}/admin-api-keys"),
flash,
config.secure_cookies(),
);
}
}
_ => {}
"revoke" => {
let Some(key_id) = form.clean("key_id") else {
return admin_api_key_flash_response(
config,
FlashData::error("API key ID is required"),
is_htmx,
);
};
let result = client.revoke_api_key(&key_id).await;
let flash = match result.log_error("revoke API key") {
Some(_) => FlashData::success("API key revoked."),
None => FlashData::error("Failed to revoke API key"),
};
flash::redirect_with_flash(
&format!("{base}/admin-api-keys"),
flash,
config.secure_cookies(),
)
}
"" => admin_api_key_flash_response(
config,
FlashData::error("API key action is required"),
is_htmx,
),
_ => admin_api_key_flash_response(
config,
FlashData::error("Unknown API key action"),
is_htmx,
),
}
flash::redirect_with_flash(
&format!("{base}/admin-api-keys"),
FlashData::success(format!("API key action '{action}' completed.")),
config.secure_cookies(),
)
}
fn admin_api_key_flash_response(
+22 -10
View File
@@ -16,7 +16,7 @@ use axum::{
use super::ActionQuery;
use super::bans_actions::{
BanFormData, custom_flash, execute_ban, extract_value, flash_response, htmx_flash,
BanFormData, custom_flash, execute_ban, extract_value, flash_response, render_inline_flash,
};
pub fn router() -> Router<AppState> {
@@ -105,7 +105,7 @@ async fn generic_ban_post(
form.audit_log_reason.as_deref(),
)
.await;
flash_response(config, auth, is_htmx, &level, &msg, ban_cfg, csrf_token)
flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token)
}
macro_rules! ban_post {
@@ -171,19 +171,22 @@ async fn url_domain_bans_post(
Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None }));
let form: BanFormData = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(_) => return htmx_flash("error", "Invalid form data", &headers),
Err(_) => return render_inline_flash("error", "Invalid form data"),
};
let is_htmx = htmx::is_htmx_request(&headers);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let domain = form.domain.as_deref().unwrap_or("").trim().to_owned();
if domain.is_empty() {
return htmx_flash("error", "Domain is required", &headers);
return render_inline_flash("error", "Domain is required");
}
let action = aq.action.as_deref().unwrap_or("");
let (level, msg) = match action {
"ban" => {
let m_sub = form.match_subdomains.as_deref() == Some("true");
match client.ban_url_domain(&domain, m_sub).await {
match client
.ban_url_domain(&domain, m_sub, form.audit_log_reason.as_deref())
.await
{
Ok(()) => ("success", format!("Domain {domain} banned successfully")),
Err(error) => {
tracing::warn!(%error, domain, "admin API request failed: ban URL domain");
@@ -191,7 +194,10 @@ async fn url_domain_bans_post(
}
}
}
"unban" => match client.unban_url_domain(&domain).await {
"unban" => match client
.unban_url_domain(&domain, form.audit_log_reason.as_deref())
.await
{
Ok(()) => ("success", format!("Domain {domain} unbanned")),
Err(error) => {
tracing::warn!(%error, domain, "admin API request failed: unban URL domain");
@@ -247,25 +253,31 @@ async fn profile_substring_bans_post(
Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None }));
let form: BanFormData = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(_) => return htmx_flash("error", "Invalid form data", &headers),
Err(_) => return render_inline_flash("error", "Invalid form data"),
};
let is_htmx = htmx::is_htmx_request(&headers);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let scope = form.scope.as_deref().unwrap_or("").trim().to_owned();
let substring = form.substring.as_deref().unwrap_or("").trim().to_owned();
if scope.is_empty() || substring.is_empty() {
return htmx_flash("error", "Scope and substring required", &headers);
return render_inline_flash("error", "Scope and substring required");
}
let action = aq.action.as_deref().unwrap_or("");
let (level, msg) = match action {
"ban" => match client.ban_profile_substring(&scope, &substring).await {
"ban" => match client
.ban_profile_substring(&scope, &substring, form.audit_log_reason.as_deref())
.await
{
Ok(()) => ("success", format!("\"{substring}\" banned for {scope}")),
Err(error) => {
tracing::warn!(%error, scope, substring, "admin API request failed: ban profile substring");
("error", format!("Failed to ban substring for {scope}"))
}
},
"unban" => match client.unban_profile_substring(&scope, &substring).await {
"unban" => match client
.unban_profile_substring(&scope, &substring, form.audit_log_reason.as_deref())
.await
{
Ok(()) => ("success", format!("\"{substring}\" unbanned for {scope}")),
Err(error) => {
tracing::warn!(%error, scope, substring, "admin API request failed: unban profile substring");
+61 -75
View File
@@ -1,17 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::client::AdminApiClient;
use crate::api::client::{AdminApiClient, ApiResult};
use crate::api::types::{FlashLevel, FlashMessage};
use crate::middleware::auth::AuthContext;
use crate::templates;
use axum::{
http::HeaderMap,
response::{Html, IntoResponse, Response},
};
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct BanFormData {
#[serde(default)]
pub ip: Option<String>,
@@ -65,7 +61,7 @@ pub async fn execute_ban(
bulk_hashes: Option<&str>,
bulk_sha256_list: Option<&str>,
audit_log_reason: Option<&str>,
) -> (String, String) {
) -> (&'static str, String) {
if (action == "bulk-ban" || action == "bulk-ban-files") && ban_type == "file-sha-bans" {
let raw_hashes = if action == "bulk-ban-files" {
bulk_sha256_list
@@ -75,13 +71,13 @@ pub async fn execute_ban(
return execute_bulk_ban(client, raw_hashes, audit_log_reason).await;
}
if value.is_empty() {
return ("error".into(), "Value is required".into());
return ("error", "Value is required".into());
}
match action {
"ban" => execute_single_ban(client, ban_type, value, audit_log_reason).await,
"unban" => execute_single_unban(client, ban_type, value, audit_log_reason).await,
"check" => execute_check(client, ban_type, value).await,
_ => ("error".into(), "Unknown action".into()),
_ => ("error", "Unknown action".into()),
}
}
@@ -89,7 +85,7 @@ async fn execute_bulk_ban(
client: &AdminApiClient,
bulk_hashes: Option<&str>,
audit_log_reason: Option<&str>,
) -> (String, String) {
) -> (&'static str, String) {
let hashes: Vec<String> = bulk_hashes
.unwrap_or("")
.split(|c: char| c.is_whitespace() || c == ',' || c == ';')
@@ -98,18 +94,15 @@ async fn execute_bulk_ban(
.collect();
if hashes.is_empty() {
return (
"error".into(),
"error",
"No valid 64-character hex hashes found in input".into(),
);
}
match client.bulk_ban_file_shas(&hashes, audit_log_reason).await {
Ok(r) => (
"success".into(),
format!("Bulk ban job created: {}", r.job_id),
),
Ok(r) => ("success", format!("Bulk ban job created: {}", r.job_id)),
Err(error) => {
tracing::warn!(%error, "admin API request failed: bulk ban file SHAs");
("error".into(), "Failed to enqueue bulk ban job".into())
("error", "Failed to enqueue bulk ban job".into())
}
}
}
@@ -119,29 +112,26 @@ async fn execute_single_ban(
ban_type: &str,
value: &str,
audit_log_reason: Option<&str>,
) -> (String, String) {
let success = format!("{value} banned successfully");
let failure = format!("Failed to ban {value}");
match ban_type {
"ip-bans" => ban_action_result(client.ban_ip(value).await, success, failure),
"email-bans" => ban_action_result(client.ban_email(value).await, success, failure),
"suspicious-email-domains" => ban_action_result(
client.add_suspicious_email_domain(value).await,
success,
failure,
),
"phrase-bans" => ban_action_result(client.ban_phrase(value).await, success, failure),
"url-bans" => ban_action_result(client.ban_url(value).await, success, failure),
"file-sha-bans" => ban_action_result(
client.ban_file_sha(value, audit_log_reason).await,
success,
failure,
),
"avatar-hash-bans" => {
ban_action_result(client.ban_avatar_hash(value).await, success, failure)
) -> (&'static str, String) {
let result = match ban_type {
"ip-bans" => client.ban_ip(value, audit_log_reason).await,
"email-bans" => client.ban_email(value, audit_log_reason).await,
"suspicious-email-domains" => {
client
.add_suspicious_email_domain(value, audit_log_reason)
.await
}
_ => ("error".into(), "Unknown ban type".into()),
}
"phrase-bans" => client.ban_phrase(value, audit_log_reason).await,
"url-bans" => client.ban_url(value, audit_log_reason).await,
"file-sha-bans" => client.ban_file_sha(value, audit_log_reason).await,
"avatar-hash-bans" => client.ban_avatar_hash(value, audit_log_reason).await,
_ => return ("error", "Unknown ban type".into()),
};
ban_action_result(
result,
format!("{value} banned successfully"),
format!("Failed to ban {value}"),
)
}
async fn execute_single_unban(
@@ -149,32 +139,33 @@ async fn execute_single_unban(
ban_type: &str,
value: &str,
audit_log_reason: Option<&str>,
) -> (String, String) {
let success = format!("{value} unbanned successfully");
let failure = format!("Failed to unban {value}");
match ban_type {
"ip-bans" => ban_action_result(client.unban_ip(value).await, success, failure),
"email-bans" => ban_action_result(client.unban_email(value).await, success, failure),
"suspicious-email-domains" => ban_action_result(
client.remove_suspicious_email_domain(value).await,
success,
failure,
),
"phrase-bans" => ban_action_result(client.unban_phrase(value).await, success, failure),
"url-bans" => ban_action_result(client.unban_url(value).await, success, failure),
"file-sha-bans" => ban_action_result(
client.unban_file_sha(value, audit_log_reason).await,
success,
failure,
),
"avatar-hash-bans" => {
ban_action_result(client.unban_avatar_hash(value).await, success, failure)
) -> (&'static str, String) {
let result = match ban_type {
"ip-bans" => client.unban_ip(value, audit_log_reason).await,
"email-bans" => client.unban_email(value, audit_log_reason).await,
"suspicious-email-domains" => {
client
.remove_suspicious_email_domain(value, audit_log_reason)
.await
}
_ => ("error".into(), "Unknown ban type".into()),
}
"phrase-bans" => client.unban_phrase(value, audit_log_reason).await,
"url-bans" => client.unban_url(value, audit_log_reason).await,
"file-sha-bans" => client.unban_file_sha(value, audit_log_reason).await,
"avatar-hash-bans" => client.unban_avatar_hash(value, audit_log_reason).await,
_ => return ("error", "Unknown ban type".into()),
};
ban_action_result(
result,
format!("{value} unbanned successfully"),
format!("Failed to unban {value}"),
)
}
async fn execute_check(client: &AdminApiClient, ban_type: &str, value: &str) -> (String, String) {
async fn execute_check(
client: &AdminApiClient,
ban_type: &str,
value: &str,
) -> (&'static str, String) {
let result = match ban_type {
"ip-bans" => client.check_ip_ban(value).await,
"email-bans" => client.check_email_ban(value).await,
@@ -183,28 +174,28 @@ async fn execute_check(client: &AdminApiClient, ban_type: &str, value: &str) ->
"url-bans" => client.check_url_ban(value).await,
"file-sha-bans" => client.check_file_sha_ban(value).await,
"avatar-hash-bans" => client.check_avatar_hash_ban(value).await,
_ => return ("error".into(), "Unknown ban type".into()),
_ => return ("error", "Unknown ban type".into()),
};
match result {
Ok(r) if r.banned => ("info".into(), format!("{value} is banned")),
Ok(_) => ("info".into(), format!("{value} is NOT banned")),
Ok(r) if r.banned => ("info", format!("{value} is banned")),
Ok(_) => ("info", format!("{value} is NOT banned")),
Err(error) => {
tracing::warn!(%error, ban_type, value, "admin API request failed: check ban status");
("error".into(), "Error checking ban status".into())
("error", "Error checking ban status".into())
}
}
}
fn ban_action_result<T, E: std::fmt::Display>(
result: Result<T, E>,
fn ban_action_result(
result: ApiResult<()>,
success_message: String,
error_message: String,
) -> (String, String) {
) -> (&'static str, String) {
match result {
Ok(_) => ("success".into(), success_message),
Ok(()) => ("success", success_message),
Err(error) => {
tracing::warn!(%error, "admin API request failed: ban action");
("error".into(), error_message)
("error", error_message)
}
}
}
@@ -228,11 +219,6 @@ pub fn flash_response(
}
}
pub fn htmx_flash(level: &str, message: &str, headers: &HeaderMap) -> Response {
let _ = headers;
render_inline_flash(level, message)
}
pub fn render_inline_flash(level: &str, message: &str) -> Response {
let (border, bg, text) = match level {
"success" => ("border-green-300", "bg-green-50", "text-green-800"),
+14 -18
View File
@@ -1,14 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::client::AdminApiClient,
api::{client::AdminApiClient, generated::types::GiftCodeDurationTypeSchema},
middleware::{
auth::AuthContext,
csrf::CsrfToken,
flash::{self, FlashData},
},
state::AppState,
templates,
templates::{self, pages::gift_codes::MAX_GIFT_CODES},
};
use axum::{
Form, Router,
@@ -28,11 +28,11 @@ struct GiftCodesForm {
#[serde(default)]
_csrf: Option<String>,
#[serde(default)]
count: Option<String>,
count: Option<u32>,
#[serde(default)]
duration_type: Option<String>,
duration_type: Option<GiftCodeDurationTypeSchema>,
#[serde(default)]
duration_quantity: Option<String>,
duration_quantity: Option<u32>,
}
pub fn router() -> Router<AppState> {
@@ -86,21 +86,17 @@ async fn gift_codes_post(
);
}
};
let count = form
.count
.as_deref()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1)
.clamp(1, 100);
let dur_type = form.duration_type.as_deref().unwrap_or("month");
let dur_qty = form
.duration_quantity
.as_deref()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1);
let count = form.count.unwrap_or(1).clamp(1, MAX_GIFT_CODES);
let duration_type = form
.duration_type
.unwrap_or(GiftCodeDurationTypeSchema::Months);
let duration_quantity = form.duration_quantity.unwrap_or(1);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let secure_cookies = config.secure_cookies();
match client.generate_gift_codes(count, dur_type, dur_qty).await {
match client
.generate_gift_codes(count, duration_type, duration_quantity)
.await
{
Ok(result) => {
let codes = result.codes.join(",");
flash::redirect_with_flash(
+4 -3
View File
@@ -43,7 +43,7 @@ pub async fn render(
let page = query.members_page.unwrap_or(0);
let limit: u32 = 50;
let resp = client
.list_guild_members(guild_id, limit, page * limit)
.list_guild_members(guild_id, limit, u64::from(page) * u64::from(limit))
.await
.log_error("load guild members")?;
Some(tabs::members::members_tab(
@@ -57,7 +57,7 @@ pub async fn render(
let page = query.reports_page.unwrap_or(0);
let limit: u32 = 25;
let resp = client
.search_reports_by_guild(guild_id, limit, page * limit)
.search_reports_by_guild(guild_id, limit, u64::from(page) * u64::from(limit))
.await
.log_error("load guild reports")?;
Some(tabs::reports::reports_tab(
@@ -121,6 +121,7 @@ pub async fn render(
admin_user_id: None,
target_id: Some(guild_id.to_owned()),
target_type: Some("guild".to_owned()),
access: Some("write".to_owned()),
sort_by: Some("created_at".to_owned()),
sort_order: Some("desc".to_owned()),
limit: 50,
@@ -134,7 +135,7 @@ pub async fn render(
@if let Some(resp) = resp {
@if resp.logs.is_empty() {
p class="text-sm text-neutral-500" {
"No admin audit log entries for this guild."
"No admin write actions have been recorded for this guild."
}
} @else {
(table_container(table(maud::html! {
+16 -16
View File
@@ -82,23 +82,19 @@ async fn guilds_list(
}
}
}
Some((guilds, Some(params.requested_ids.len() as u64), false))
Some((guilds, params.requested_ids.len() as u64))
} else if params.has_search() {
let offset = params.page.saturating_mul(params.limit);
let offset = u64::from(params.page) * u64::from(params.limit);
client
.search_guilds(params.search_query(), params.limit, offset)
.await
.log_error("search guilds")
.map(|response| {
let has_more = u64::from(offset) + (response.guilds.len() as u64) < response.total;
(response.guilds, Some(response.total), has_more)
})
.map(|response| (response.guilds, response.total))
} else {
None
};
let result_guilds = results.as_ref().map(|result| result.0.as_slice());
let total = results.as_ref().and_then(|result| result.1);
let has_more = results.as_ref().is_some_and(|result| result.2);
let total = results.as_ref().map(|result| result.1);
let markup = templates::pages::guilds_list::guilds_list_page(
config,
@@ -106,7 +102,6 @@ async fn guilds_list(
&params,
result_guilds,
total,
has_more,
is_results_fragment,
);
Html(markup.into_string()).into_response()
@@ -324,8 +319,12 @@ async fn dispatch_guild_action(
"default_message_notifications",
"disabled_operations",
] {
if let Some(v) = form.parse_i64(key) {
settings.insert(key.to_string(), serde_json::json!(v));
let value = match form.parse_value::<i64>(key) {
Ok(value) => value,
Err(_) => return FlashData::error(format!("Invalid {key} value")),
};
if let Some(value) = value {
settings.insert(key.to_string(), serde_json::json!(value));
}
}
if form.contains_key("nsfw_submitted") {
@@ -356,11 +355,12 @@ async fn dispatch_guild_action(
)
}
"update_disabled_operations" => {
let disabled_operations = form
.list_values_any(&["disabled_operations[]", "disabled_operations"])
.iter()
.filter_map(|value| value.parse::<i64>().ok())
.fold(0_i64, |acc, value| acc | value);
let Ok(operations) =
form.parse_list_values::<i64>(&["disabled_operations[]", "disabled_operations"])
else {
return FlashData::error("Invalid disabled operation value");
};
let disabled_operations = operations.into_iter().fold(0_i64, |acc, value| acc | value);
let settings = serde_json::json!({
"disabled_operations": disabled_operations,
});
+31 -45
View File
@@ -21,7 +21,6 @@ use axum::{
use serde::Deserialize;
#[derive(Deserialize)]
#[allow(dead_code)]
struct JobsQuery {
status: Option<String>,
task_type: Option<String>,
@@ -219,49 +218,36 @@ async fn job_detail_post(
}
fn jobs_url(config: &crate::config::AdminConfig, query: &JobsQuery) -> String {
let mut params = Vec::new();
push_query(&mut params, "status", query.status.as_deref());
push_query(&mut params, "task_type", query.task_type.as_deref());
push_query(
&mut params,
"requested_by_user_id",
query.requested_by_user_id.as_deref(),
);
push_query(
&mut params,
"max_lookback_days",
query.max_lookback_days.as_deref(),
);
push_query(
&mut params,
"cursor_bucket_day",
query.cursor_bucket_day.as_deref(),
);
push_query(
&mut params,
"cursor_created_at",
query.cursor_created_at.as_deref(),
);
push_query(&mut params, "cursor_job_id", query.cursor_job_id.as_deref());
if params.is_empty() {
return format!("{}/jobs", config.base_path);
}
let query = params
.iter()
.map(|(key, value)| {
format!(
"{}={}",
urlencoding::encode(key),
urlencoding::encode(value)
)
})
.collect::<Vec<_>>()
.join("&");
format!("{}/jobs?{query}", config.base_path)
}
fn push_query(params: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
if let Some(value) = value.filter(|value| !value.is_empty()) {
params.push((key.to_owned(), value.to_owned()));
let query = [
("status", query.status.as_deref()),
("task_type", query.task_type.as_deref()),
(
"requested_by_user_id",
query.requested_by_user_id.as_deref(),
),
("max_lookback_days", query.max_lookback_days.as_deref()),
("cursor_bucket_day", query.cursor_bucket_day.as_deref()),
("cursor_created_at", query.cursor_created_at.as_deref()),
("cursor_job_id", query.cursor_job_id.as_deref()),
]
.into_iter()
.filter_map(|(key, value)| {
value
.filter(|value| !value.is_empty())
.map(|value| (key, value))
})
.map(|(key, value)| {
format!(
"{}={}",
urlencoding::encode(key),
urlencoding::encode(value)
)
})
.collect::<Vec<_>>()
.join("&");
if query.is_empty() {
format!("{}/jobs", config.base_path)
} else {
format!("{}/jobs?{query}", config.base_path)
}
}
+29 -24
View File
@@ -59,53 +59,51 @@ pub(crate) async fn messages_post(
match action {
"lookup" => {
let context_limit = parse_context_limit(form.first("context_limit"));
return Redirect::to(&format!(
Redirect::to(&format!(
"{base}/messages?channel_id={}&message_id={}&context_limit={context_limit}",
encode_opt(&channel_id),
encode_opt(&message_id)
))
.into_response();
.into_response()
}
"lookup-by-attachment" => {
let attachment_id = form.clean("attachment_id");
let filename = form.clean("filename");
let context_limit = parse_context_limit(form.first("context_limit"));
return Redirect::to(&format!(
Redirect::to(&format!(
"{base}/messages?channel_id={}&attachment_id={}&filename={}&context_limit={context_limit}",
encode_opt(&channel_id),
encode_opt(&attachment_id),
encode_opt(&filename)
))
.into_response();
}
"browse" => {
return Redirect::to(&format!(
"{base}/messages?channel_id={}",
encode_opt(&channel_id)
))
.into_response();
.into_response()
}
"browse" => Redirect::to(&format!(
"{base}/messages?channel_id={}",
encode_opt(&channel_id)
))
.into_response(),
"search" => {
let search = form.clean("search");
return Redirect::to(&format!(
Redirect::to(&format!(
"{base}/messages?channel_id={}&search={}",
encode_opt(&channel_id),
encode_opt(&search)
))
.into_response();
.into_response()
}
"delete" => {
let (Some(cid), Some(mid)) = (&channel_id, &message_id) else {
return json_error(StatusCode::BAD_REQUEST, "Missing channel_id or message_id");
};
let audit_log_reason = form.clean("audit_log_reason");
return match client
match client
.delete_message(cid, mid, audit_log_reason.as_deref())
.await
{
Ok(()) => Json(serde_json::json!({"success": true})).into_response(),
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
};
}
}
"report-to-ncmec" => {
let attachment_id = form.clean("attachment_id");
@@ -131,7 +129,7 @@ pub(crate) async fn messages_post(
"Missing required NCMEC report fields",
);
}
return match client
match client
.report_attachment_to_ncmec(
cid,
mid,
@@ -144,11 +142,10 @@ pub(crate) async fn messages_post(
{
Ok(resp) => Json(resp.data).into_response(),
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
};
}
}
_ => {}
_ => Redirect::to(&format!("{base}/messages")).into_response(),
}
Redirect::to(&format!("{base}/messages")).into_response()
}
pub(crate) async fn system_dms_post(
@@ -253,14 +250,22 @@ pub(crate) async fn bulk_actions_post(
}
"bulk-schedule-user-deletion" | "bulk_delete_users" => {
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
let reason_code = form.parse_u32("reason_code").unwrap_or(2);
let days = form.parse_u32("days_until_deletion").unwrap_or(14);
let (Ok(reason_code), Ok(days)) = (
form.parse_value::<u32>("reason_code"),
form.parse_value::<u32>("days_until_deletion"),
) else {
return flash::redirect_with_flash(
&format!("{base}/bulk-actions"),
FlashData::error("Invalid deletion reason code or delay"),
config.secure_cookies(),
);
};
let public_reason = form.clean("public_reason");
client
.bulk_schedule_user_deletion(
&user_ids,
reason_code,
days,
reason_code.unwrap_or(2),
days.unwrap_or(14),
public_reason.as_deref(),
audit_log_reason.as_deref(),
)
@@ -357,7 +362,7 @@ pub(crate) async fn messages_browse_fragment(
}
}
fn parse_context_limit(value: Option<&str>) -> u32 {
pub(super) fn parse_context_limit(value: Option<&str>) -> u32 {
value
.and_then(|s| s.parse::<u32>().ok())
.filter(|n| *n > 0)
+1 -8
View File
@@ -16,7 +16,6 @@ use axum::{
use serde::Deserialize;
#[derive(Deserialize)]
#[allow(dead_code)]
struct MessagesQuery {
channel_id: Option<String>,
message_id: Option<String>,
@@ -82,13 +81,7 @@ async fn messages_page(
let before = query.before.as_deref().filter(|s| !s.is_empty());
let after = query.after.as_deref().filter(|s| !s.is_empty());
let search = query.search.as_deref().filter(|s| !s.is_empty());
let context_limit = query
.context_limit
.as_deref()
.and_then(|s| s.parse::<u32>().ok())
.filter(|n| *n > 0)
.unwrap_or(50)
.min(100);
let context_limit = super::message_actions::parse_context_limit(query.context_limit.as_deref());
let mut lookup_result = None;
let mut browse_result = None;
let mut search_result = None;
+2 -2
View File
@@ -26,7 +26,7 @@ use axum::{
};
use serde::Deserialize;
const MAX_REPORT_OFFSET: u32 = 10_000;
const MAX_REPORT_OFFSET: u64 = 10_000;
#[derive(Deserialize)]
struct ReportsQuery {
@@ -75,7 +75,7 @@ async fn reports_list(
let config = state.config();
let page = query.page.unwrap_or(0);
let limit = query.limit.unwrap_or(25).clamp(1, 200);
let offset = page.saturating_mul(limit);
let offset = u64::from(page) * u64::from(limit);
if offset > MAX_REPORT_OFFSET {
return reports_error_page(
config,
+4 -3
View File
@@ -17,19 +17,18 @@ use serde::Deserialize;
use super::system_actions;
#[derive(Deserialize)]
#[allow(dead_code)]
struct GatewayQuery {
leaderboard_limit: Option<String>,
node_stats: Option<String>,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct AuditLogsQuery {
q: Option<String>,
admin_user_id: Option<String>,
target_id: Option<String>,
target_type: Option<String>,
access: Option<String>,
sort_by: Option<String>,
sort_order: Option<String>,
limit: Option<u32>,
@@ -121,6 +120,7 @@ async fn audit_logs_page(
admin_user_id: query.admin_user_id.as_deref().unwrap_or(""),
target_id: query.target_id.as_deref().unwrap_or(""),
target_type: query.target_type.as_deref().unwrap_or(""),
access: query.access.as_deref().unwrap_or(""),
sort_by: query.sort_by.as_deref().unwrap_or("createdAt"),
sort_order: query.sort_order.as_deref().unwrap_or("desc"),
limit,
@@ -133,10 +133,11 @@ async fn audit_logs_page(
admin_user_id: nonempty(params.admin_user_id),
target_id: nonempty(params.target_id),
target_type: nonempty(params.target_type),
access: nonempty(params.access),
sort_by: Some(params.sort_by.to_owned()),
sort_order: Some(params.sort_order.to_owned()),
limit,
offset: current_page * limit,
offset: u64::from(current_page) * u64::from(limit),
};
let result = client
.search_audit_logs(&search_params)
@@ -683,6 +683,8 @@ fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
wordmark_url: optional("app_wordmark_url"),
favicon_url: optional("app_favicon_url"),
theme_color: optional("app_theme_color"),
status_page_url: optional("app_status_page_url"),
status_page_incident_history_url: optional("app_status_page_incident_history_url"),
}),
setup: Some(AppSetupConfigUpdateRequest {
configured: Some(form.bool_value("app_setup_configured")),
+44 -31
View File
@@ -60,7 +60,9 @@ pub async fn dispatch(
"Failed to update user flags",
);
}
let submitted = parse_u64_list(form, &["flags[]", "flags"]);
let Ok(submitted) = form.parse_list_values::<u64>(&["flags[]", "flags"]) else {
return DispatchOutcome::error("Invalid user flag value");
};
let selected = submitted.iter().copied().collect::<HashSet<_>>();
let user = match client.get_user_by_id(user_id).await {
Ok(user) => user,
@@ -89,15 +91,22 @@ pub async fn dispatch(
}
"update_premium_flags" => {
if has_legacy_flag_delta_fields(form) {
let add = parse_i32_list(form, &["add_flags[]", "add_flags"]);
let remove = parse_i32_list(form, &["remove_flags[]", "remove_flags"]);
let Ok(add) = form.parse_list_values::<i32>(&["add_flags[]", "add_flags"]) else {
return DispatchOutcome::error("Invalid premium flag value to add");
};
let Ok(remove) = form.parse_list_values::<i32>(&["remove_flags[]", "remove_flags"])
else {
return DispatchOutcome::error("Invalid premium flag value to remove");
};
return DispatchOutcome::from_result(
client.update_premium_flags(user_id, &add, &remove).await,
"Premium flags updated successfully",
"Failed to update premium flags",
);
}
let submitted = parse_i32_list(form, &["flags[]", "flags"]);
let Ok(submitted) = form.parse_list_values::<i32>(&["flags[]", "flags"]) else {
return DispatchOutcome::error("Invalid premium flag value");
};
let selected = submitted.iter().copied().collect::<HashSet<_>>();
let user = match client.get_user_by_id(user_id).await {
Ok(user) => user,
@@ -124,9 +133,12 @@ pub async fn dispatch(
)
}
"update_suspicious_flags" => {
let flags = parse_i32_list(form, &["suspicious_flags[]", "suspicious_flags"])
.into_iter()
.fold(0, |acc, flag| acc | flag);
let Ok(submitted) =
form.parse_list_values::<i32>(&["suspicious_flags[]", "suspicious_flags"])
else {
return DispatchOutcome::error("Invalid suspicious activity flag value");
};
let flags = submitted.into_iter().fold(0, |acc, flag| acc | flag);
DispatchOutcome::from_result(
client.update_suspicious_flags(user_id, flags).await,
"Suspicious activity flags updated successfully",
@@ -225,15 +237,19 @@ pub async fn dispatch(
)
}
"temp_ban" => {
let dur = form
.parse_u32("duration_hours")
.or_else(|| form.parse_u32("duration"))
.unwrap_or(24);
let Ok(duration) = form.parse_value_any::<u32>(&["duration_hours", "duration"]) else {
return DispatchOutcome::error("Invalid ban duration");
};
let reason = get("reason");
let private = get("private_reason");
DispatchOutcome::from_result(
client
.temp_ban_user(user_id, dur, reason.as_deref(), private.as_deref())
.temp_ban_user(
user_id,
duration.unwrap_or(24),
reason.as_deref(),
private.as_deref(),
)
.await,
"User temporarily banned successfully",
"Failed to temporarily ban user",
@@ -249,7 +265,7 @@ pub async fn dispatch(
return DispatchOutcome::error("IP address is required");
};
DispatchOutcome::from_result(
client.ban_ip(&ip).await,
client.ban_ip(&ip, None).await,
"IP banned successfully",
"Failed to ban IP",
)
@@ -259,18 +275,29 @@ pub async fn dispatch(
return DispatchOutcome::error("Avatar hash is required");
};
DispatchOutcome::from_result(
client.ban_avatar_hash(&hash).await,
client.ban_avatar_hash(&hash, None).await,
"Avatar hash banned successfully",
"Failed to ban avatar hash",
)
}
"schedule_deletion" => {
let reason_code = form.parse_i32("reason_code").unwrap_or(0);
let Ok(reason_code) = form.parse_value::<i32>("reason_code") else {
return DispatchOutcome::error("Invalid deletion reason code");
};
let public_reason = get("public_reason");
let days = form.parse_u32("days_until_deletion").unwrap_or(60);
let private_reason = get("private_reason");
let Ok(days) = form.parse_value_any::<u32>(&["days_until_deletion", "days"]) else {
return DispatchOutcome::error("Invalid deletion delay");
};
DispatchOutcome::from_result(
client
.schedule_deletion(user_id, reason_code, public_reason.as_deref(), days)
.schedule_deletion(
user_id,
reason_code.unwrap_or(0),
public_reason.as_deref(),
days.unwrap_or(60),
private_reason.as_deref(),
)
.await,
"User deletion scheduled successfully",
"Failed to schedule user deletion",
@@ -441,20 +468,6 @@ fn is_relationship_category(category: &str) -> bool {
)
}
fn parse_u64_list(form: &MultiValueForm, keys: &[&str]) -> Vec<u64> {
form.list_values_any(keys)
.iter()
.filter_map(|value| value.parse().ok())
.collect()
}
fn parse_i32_list(form: &MultiValueForm, keys: &[&str]) -> Vec<i32> {
form.list_values_any(keys)
.iter()
.filter_map(|value| value.parse().ok())
.collect()
}
fn parse_dry_run(value: Option<&str>) -> bool {
!matches!(value.map(|value| value.trim().to_ascii_lowercase()), Some(value) if value == "false" || value == "0")
}
+17 -12
View File
@@ -164,25 +164,29 @@ pub async fn render(
Some(tabs::guilds::guilds_tab(config, user_id, &g))
}
"reports" => {
let lim = query.reports_limit.unwrap_or(25);
let sp = query.reports_sent_page.unwrap_or(0);
let rp = query.reports_received_page.unwrap_or(0);
let limit = query.reports_limit.unwrap_or(25);
let sent_page = query.reports_sent_page.unwrap_or(0);
let received_page = query.reports_received_page.unwrap_or(0);
let sent = client
.search_reports_by_reporter(user_id, lim, sp * lim)
.search_reports_by_reporter(user_id, limit, u64::from(sent_page) * u64::from(limit))
.await
.log_error("load reports sent by user");
let recv = client
.search_reports_by_reported_user(user_id, lim, rp * lim)
let received = client
.search_reports_by_reported_user(
user_id,
limit,
u64::from(received_page) * u64::from(limit),
)
.await
.log_error("load reports against user");
Some(tabs::reports::reports_tab(
config,
user_id,
sent.as_ref(),
recv.as_ref(),
sp,
rp,
lim,
received.as_ref(),
sent_page,
received_page,
limit,
))
}
"relationships" => {
@@ -240,11 +244,12 @@ pub async fn render(
query: None,
admin_user_id: None,
target_id: Some(user_id.to_owned()),
target_type: Some("user".to_owned()),
target_type: None,
access: Some("write".to_owned()),
sort_by: Some("created_at".to_owned()),
sort_order: Some("desc".to_owned()),
limit,
offset: page * limit,
offset: u64::from(page) * u64::from(limit),
})
.await
.log_error("load user admin audit logs")?;
+2 -2
View File
@@ -93,7 +93,7 @@ async fn users_list(
.log_error("lookup users by ids")
.map(|users| (users, false))
} else if params.has_search() {
let offset = params.page.saturating_mul(params.limit);
let offset = u64::from(params.page) * u64::from(params.limit);
client
.search_users(
params.search_query(),
@@ -105,7 +105,7 @@ async fn users_list(
.await
.log_error("search users")
.map(|r| {
let has_more = u64::from(offset) + (r.users.len() as u64) < r.total;
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
(r.users, has_more)
})
} else {
@@ -41,17 +41,14 @@ pub fn archives_tab(
}
}
fn status_text(archive: &Archive) -> String {
fn status_text(archive: &Archive) -> &str {
if archive.failed_at.is_some() {
return "Failed".to_owned();
return "Failed";
}
if archive.completed_at.is_some() {
return "Completed".to_owned();
return "Completed";
}
archive
.progress_step
.clone()
.unwrap_or_else(|| "In Progress".to_owned())
archive.progress_step.as_deref().unwrap_or("In Progress")
}
fn archives_table(base: &str, archives: &[Archive]) -> Markup {
@@ -66,9 +66,15 @@ pub fn audit_logs_for_target(
) -> Markup {
let has_previous = current_page > 0;
let offset = (current_page as u64) * (PAGE_SIZE as u64);
let has_next = offset + (entries.len() as u64) < total;
let total_pages = ((total as f64) / (PAGE_SIZE as f64)).ceil().max(1.0) as u64;
let all_logs_href = format!("{base_path}/audit-logs?target_id={target_id}");
let next_page = current_page
.checked_add(1)
.filter(|_| offset + (entries.len() as u64) < total);
let total_pages = total.div_ceil(u64::from(PAGE_SIZE)).max(1);
let page_number = u64::from(current_page) + 1;
let all_logs_href = format!(
"{base_path}/audit-logs?target_id={}&access=write",
urlencoding::encode(target_id)
);
html! {
div class="rounded-lg bg-white transition-all border border-neutral-200 p-6" {
@@ -88,7 +94,7 @@ pub fn audit_logs_for_target(
}
}
@if entries.is_empty() {
(empty_state("No admin actions have been recorded against this entity."))
(empty_state("No admin write actions have been recorded against this entity."))
} @else {
(table_container(html! {
(table(html! {
@@ -108,7 +114,7 @@ pub fn audit_logs_for_target(
}))
}))
}
@if has_previous || has_next {
@if has_previous || next_page.is_some() {
div class="flex items-center justify-center gap-2" {
@if has_previous {
a href={(tab_href_base) "&audit_logs_page=" (current_page - 1)}
@@ -119,10 +125,10 @@ pub fn audit_logs_for_target(
}
}
span class="text-sm text-neutral-500" {
"Page " (current_page + 1) " of " (total_pages)
"Page " (page_number) " of " (total_pages)
}
@if has_next {
a href={(tab_href_base) "&audit_logs_page=" (current_page + 1)}
@if let Some(page) = next_page {
a href={(tab_href_base) "&audit_logs_page=" (page)}
class="inline-flex items-center justify-center gap-2 font-medium \
rounded-lg bg-neutral-50 text-neutral-700 border \
border-neutral-300 px-3 py-1.5 text-sm" {
+9 -14
View File
@@ -81,20 +81,15 @@ pub fn guild_asset_url(
}
pub fn initials(name: &str) -> String {
let parts = name
.split_whitespace()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
match parts.as_slice() {
[] => "?".to_owned(),
[part] => part.chars().next().unwrap_or('?').to_uppercase().collect(),
[first, .., last] => {
let mut value = String::new();
value.extend(first.chars().next().unwrap_or('?').to_uppercase());
value.extend(last.chars().next().unwrap_or('?').to_uppercase());
value
}
}
let mut parts = name.split_whitespace();
let Some(first) = parts.next() else {
return "?".to_owned();
};
std::iter::once(first)
.chain(parts.next_back())
.flat_map(|part| part.chars().take(1))
.flat_map(char::to_uppercase)
.collect()
}
fn media_asset_url(
@@ -45,8 +45,8 @@ fn ncmec_badge(att: &Attachment) -> Markup {
}
fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
let images: Vec<&Attachment> = msg.attachments.iter().filter(|a| is_image(a)).collect();
if images.is_empty() {
let mut images = msg.attachments.iter().filter(|a| is_image(a)).peekable();
if images.peek().is_none() {
return html! {};
}
let spacer = if !msg.content.is_empty() {
@@ -56,7 +56,7 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
};
html! {
div class=(spacer) {
@for att in &images {
@for att in images {
div class="max-w-xl overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50" {
a href=(att.url) target="_blank" rel="noopener noreferrer"
class="block overflow-hidden bg-neutral-100" {
@@ -116,8 +116,8 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
}
fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Markup {
let others: Vec<&Attachment> = msg.attachments.iter().filter(|a| !is_image(a)).collect();
if others.is_empty() {
let mut others = msg.attachments.iter().filter(|a| !is_image(a)).peekable();
if others.peek().is_none() {
return html! {};
}
let spacer = if has_content_or_images {
@@ -127,7 +127,7 @@ fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Marku
};
html! {
div class=(spacer) {
@for att in &others {
@for att in others {
div class="flex flex-wrap items-center gap-2 text-xs" {
(paperclip_icon("text-neutral-400"))
a href=(att.url) target="_blank" rel="noopener noreferrer"
@@ -17,7 +17,6 @@ pub mod message_data;
pub mod message_list;
pub mod nsfw_indicators;
pub mod page_container;
pub mod pagination;
pub mod resource_link;
pub mod section_card;
pub mod stack;
@@ -19,7 +19,7 @@ pub fn adult_content_badge(is_adult: bool, label: Option<&str>) -> Markup {
}
fn truncate(text: &str, max: usize) -> String {
if text.len() <= max {
if text.chars().nth(max).is_none() {
text.to_owned()
} else {
let boundary = max.saturating_sub(1);
@@ -29,10 +29,7 @@ fn truncate(text: &str, max: usize) -> String {
}
pub fn content_warning_badge(level: Option<i32>, text: Option<&str>, inline_text: bool) -> Markup {
let Some(lvl) = level else {
return html! {};
};
if lvl != CONTENT_WARNING_LEVEL {
if level != Some(CONTENT_WARNING_LEVEL) {
return html! {};
}
let default_text = "This contains sensitive content.";
@@ -82,13 +79,8 @@ fn resolve_nsfw_source(
fn source_label(source: NsfwSource, explicit: Option<bool>) -> &'static str {
match source {
NsfwSource::Channel => {
if explicit == Some(true) {
"(override on)"
} else {
"(override off)"
}
}
NsfwSource::Channel if explicit == Some(true) => "(override on)",
NsfwSource::Channel => "(override off)",
NsfwSource::Category => "(from category)",
NsfwSource::Community => "(from community)",
NsfwSource::None => "",
@@ -110,11 +102,7 @@ pub fn channel_nsfw_state_badge(
}
let has_context =
nsfw_override.is_some() || category_override.is_some() || guild_nsfw.is_some();
let (source, explicit) = if has_context {
resolve_nsfw_source(nsfw_override, category_override, guild_nsfw)
} else {
(NsfwSource::None, None)
};
let (source, explicit) = resolve_nsfw_source(nsfw_override, category_override, guild_nsfw);
html! {
span class="inline-flex flex-wrap items-center gap-1" {
@if is_nsfw {
@@ -1,34 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use maud::{Markup, PreEscaped, html};
pub fn pagination(base_url: &str, current_page: u32, has_more: bool, extra_params: &str) -> Markup {
let sep = if base_url.contains('?') { "&" } else { "?" };
let has_previous = current_page > 1;
html! {
div class="mt-4 flex items-center justify-between" {
@if has_previous {
p class="text-sm font-normal text-neutral-500" {
a href={
(base_url) (sep) "page=" (current_page - 1) (extra_params)
} class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 hover:text-neutral-900" {
(PreEscaped("&larr; Previous"))
}
}
} @else {
span {}
}
@if has_more {
p class="text-sm font-normal text-neutral-500" {
a href={
(base_url) (sep) "page=" (current_page + 1) (extra_params)
} class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 hover:text-neutral-900" {
(PreEscaped("Next &rarr;"))
}
}
} @else {
span {}
}
}
}
}
@@ -12,8 +12,6 @@ const RESOURCE_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral
hover:text-neutral-600 hover:decoration-neutral-500 text-sm";
const NAV_LINK_CLASS: &str = "label rounded-lg border border-neutral-300 bg-white \
px-3 py-2 text-neutral-700 transition-colors hover:bg-neutral-50";
const TEXT_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral-300 \
hover:text-neutral-600 hover:decoration-neutral-500";
impl ResourceType {
fn path_segment(self) -> &'static str {
@@ -91,18 +89,3 @@ pub fn nav_link(href: &str, content: Markup) -> Markup {
a href=(href) class=(NAV_LINK_CLASS) { (content) }
}
}
pub fn text_link(href: &str, content: Markup, external: bool, _mono: bool) -> Markup {
if external {
html! {
a href=(href) class=(TEXT_LINK_CLASS)
target="_blank" rel="noopener noreferrer" {
(content)
}
}
} else {
html! {
a href=(href) class=(TEXT_LINK_CLASS) { (content) }
}
}
}
@@ -54,6 +54,7 @@ pub const NAV_SECTIONS: &[NavSection] = &[
"bulk-actions",
[
acl::BULK_UPDATE_USER_FLAGS,
acl::BULK_UPDATE_SUSPICIOUS_ACTIVITY,
acl::BULK_UPDATE_GUILD_FEATURES,
acl::BULK_ADD_GUILD_MEMBERS,
acl::BULK_DELETE_USERS,
@@ -264,3 +265,27 @@ pub const NAV_SECTIONS: &[NavSection] = &[
)],
},
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bulk_actions_nav_covers_every_acl_the_page_renders_a_section_for() {
let item = NAV_SECTIONS
.iter()
.flat_map(|section| section.items)
.find(|item| item.active_key == "bulk-actions")
.expect("bulk actions nav item");
for required in [
acl::BULK_UPDATE_USER_FLAGS,
acl::BULK_UPDATE_SUSPICIOUS_ACTIVITY,
acl::BULK_UPDATE_GUILD_FEATURES,
acl::BULK_ADD_GUILD_MEMBERS,
acl::BULK_DELETE_USERS,
acl::BULK_DELETE_USER_MESSAGES,
] {
assert!(item.required_acls.contains(&required), "{required}");
}
}
}
+79 -25
View File
@@ -22,6 +22,7 @@ pub struct AuditLogsParams<'a> {
pub admin_user_id: &'a str,
pub target_id: &'a str,
pub target_type: &'a str,
pub access: &'a str,
pub sort_by: &'a str,
pub sort_order: &'a str,
pub limit: u32,
@@ -33,6 +34,7 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
("", "Any"),
("user", "User"),
("guild", "Guild"),
("bulk_job", "Bulk job"),
("email_domain", "Email domain"),
("ip", "IP"),
("phrase", "Phrase"),
@@ -41,6 +43,11 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
("file_sha", "File SHA"),
("email", "Email"),
];
let access_options: &[(&str, &str)] = &[
("", "All entries"),
("write", "Writes only"),
("read", "Reads only"),
];
let sort_options: &[(&str, &str)] = &[("createdAt", "Created at"), ("relevance", "Relevance")];
let order_options: &[(&str, &str)] = &[("desc", "Newest first"), ("asc", "Oldest first")];
let limit_options: &[(&str, &str)] =
@@ -59,6 +66,7 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
"Filter by admin user ID..."))
(select_input("target_type", "Target type",
target_type_options, params.target_type))
(select_input("access", "Access", access_options, params.access))
(select_input("sort_by", "Sort by", sort_options, params.sort_by))
(select_input("sort_order", "Order", order_options, params.sort_order))
(select_input("limit", "Page size", limit_options, &limit_str))
@@ -72,27 +80,23 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
}
fn build_pagination_url(base: &str, page: u32, params: &AuditLogsParams<'_>) -> String {
let mut url = format!("{base}/audit-logs?page={page}");
if !params.query.is_empty() {
url.push_str(&format!("&q={}", params.query));
}
if !params.admin_user_id.is_empty() {
url.push_str(&format!("&admin_user_id={}", params.admin_user_id));
}
if !params.target_id.is_empty() {
url.push_str(&format!("&target_id={}", params.target_id));
}
if !params.target_type.is_empty() {
url.push_str(&format!("&target_type={}", params.target_type));
}
if !params.sort_by.is_empty() {
url.push_str(&format!("&sort_by={}", params.sort_by));
}
if !params.sort_order.is_empty() {
url.push_str(&format!("&sort_order={}", params.sort_order));
}
url.push_str(&format!("&limit={}", params.limit));
url
let mut query = url::form_urlencoded::Serializer::new(String::new());
query.append_pair("page", &page.to_string());
query.extend_pairs(
[
("q", params.query),
("admin_user_id", params.admin_user_id),
("target_id", params.target_id),
("target_type", params.target_type),
("access", params.access),
("sort_by", params.sort_by),
("sort_order", params.sort_order),
]
.into_iter()
.filter(|(_, value)| !value.is_empty()),
);
query.append_pair("limit", &params.limit.to_string());
format!("{base}/audit-logs?{}", query.finish())
}
pub fn audit_logs_page(
@@ -107,10 +111,15 @@ pub fn audit_logs_page(
let total = data.total;
let entries = &data.logs;
let total_pages = if params.limit > 0 {
((total as f64) / (params.limit as f64)).ceil().max(1.0) as u64
total.div_ceil(u64::from(params.limit)).max(1)
} else {
1
};
let page_number = u64::from(params.current_page) + 1;
let next_page = params
.current_page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
let showing = format!("Showing {} of {} entries", entries.len(), total);
html! {
(page_header_with_actions("Audit Logs", None, html! {
@@ -134,10 +143,10 @@ pub fn audit_logs_page(
}
} @else { span {} }
span class="text-sm text-neutral-500" {
"Page " (params.current_page + 1) " of " (total_pages)
"Page " (page_number) " of " (total_pages)
}
@if (params.current_page + 1) < total_pages as u32 {
a href=(build_pagination_url(base, params.current_page + 1, params))
@if let Some(page) = next_page {
a href=(build_pagination_url(base, page, params))
class="text-sm text-neutral-900 underline" {
(PreEscaped("Next &rarr;"))
}
@@ -156,3 +165,48 @@ pub fn audit_logs_page(
};
admin_layout(config, auth, "Audit Logs", "audit-logs", None, content)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_type_filter_offers_bulk_jobs() {
let params = AuditLogsParams {
query: "",
admin_user_id: "",
target_id: "",
target_type: "bulk_job",
access: "",
sort_by: "createdAt",
sort_order: "desc",
limit: 50,
current_page: 0,
};
let markup = filters_section("/admin", &params).into_string();
assert!(markup.contains(r#"<option value="bulk_job" selected>Bulk job</option>"#));
assert!(markup.contains(r#"<option value="" selected>All entries</option>"#));
}
#[test]
fn access_filter_survives_form_and_pagination() {
let params = AuditLogsParams {
query: "",
admin_user_id: "",
target_id: "1500000000000000001",
target_type: "",
access: "read",
sort_by: "createdAt",
sort_order: "desc",
limit: 50,
current_page: 0,
};
let markup = filters_section("/admin", &params).into_string();
assert!(markup.contains(r#"<select id="access" name="access""#));
assert!(markup.contains(r#"<option value="read" selected>Reads only</option>"#));
assert_eq!(
build_pagination_url("/admin", 1, &params),
"/admin/audit-logs?page=1&target_id=1500000000000000001&access=read&sort_by=createdAt&sort_order=desc&limit=50"
);
}
}
@@ -14,12 +14,7 @@ use crate::{
use maud::{Markup, html};
pub fn format_action(action: &str) -> String {
let replaced = action.replace('_', " ");
let mut chars = replaced.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
capitalise(&action.replace('_', " "))
}
pub fn action_badge_variant(action: &str) -> BadgeVariant {
@@ -92,10 +87,8 @@ pub fn admin_user_cell(base: &str, entry: &AuditLogEntry) -> Markup {
}
}
fn target_guild_label(guild: Option<&AuditLogGuildSummary>) -> String {
guild
.map(|guild| guild.name.clone())
.unwrap_or_else(|| "Guild".to_owned())
fn target_guild_label(guild: Option<&AuditLogGuildSummary>) -> &str {
guild.map(|guild| guild.name.as_str()).unwrap_or("Guild")
}
pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
@@ -133,6 +126,14 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
}
}))
},
"bulk_job" => html! {
(job_link(base, &entry.target_id, html! {
div class="flex flex-col" {
span class="text-sm font-medium" { "Bulk job" }
span class="text-xs text-neutral-500 break-all" { "ID: " (&entry.target_id) }
}
}))
},
"message" => html! {
div class="flex flex-col" {
span class="text-sm font-medium" { "Message" }
@@ -154,6 +155,15 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
}
}
fn job_link(base: &str, job_id: &str, display: Markup) -> Markup {
html! {
a href={(base) "/jobs/" (job_id)} title={"Job " (job_id)}
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 text-sm" {
(display)
}
}
}
fn channel_href(base: &str, channel_id: &str) -> String {
format!(
"{base}/messages?channel_id={}&context_limit=50",
@@ -317,3 +327,36 @@ pub fn audit_log_table_body(base: &str, entries: &[AuditLogEntry]) -> Markup {
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(target_type: &str, target_id: &str) -> AuditLogEntry {
serde_json::from_value(serde_json::json!({
"log_id": "1900000000000000001",
"admin_user_id": "1500000000000000001",
"action": "bulk_schedule_deletion",
"target_id": target_id,
"target_type": target_type,
"audit_log_reason": "Raid cleanup",
"created_at": "2026-09-14T12:00:00.000Z"
}))
.expect("audit log fixture must deserialize")
}
#[test]
fn bulk_job_targets_link_to_the_job_detail_page() {
let markup = target_cell("/admin", &entry("bulk_job", "1900000000000000002")).into_string();
assert!(markup.contains(r#"href="/admin/jobs/1900000000000000002""#));
assert!(markup.contains("Bulk job"));
assert!(!markup.contains("/admin/users/"));
}
#[test]
fn unknown_target_types_stay_unlinked() {
let markup = target_cell("/admin", &entry("email_domain", "spam.example")).into_string();
assert!(!markup.contains("<a "));
assert!(markup.contains("Email domain"));
}
}
@@ -7,8 +7,8 @@ use crate::{
templates::{
components::{
form::{
checkbox, csrf_input, danger_button, form_actions, form_field_group, select_input,
submit_button, text_input, textarea_input,
FORM_SELECT_CLASS, checkbox, csrf_input, danger_button, form_actions,
form_field_group, select_chevron, submit_button, text_input, textarea_input,
},
page_container::page_header,
section_card::section_card_simple,
@@ -395,16 +395,33 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
(csrf_input(csrf_token))
div class="space-y-4" {
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
(select_input("reason_code", "Deletion Reason", DELETION_REASONS, "1"))
(form_field_group("Deletion Reason", "reason_code", true, None,
Some("User requested skips identifier bans and pending report resolution. Every other reason applies them."),
html! {
div class="relative" {
select id="reason_code" name="reason_code" required
class=(FORM_SELECT_CLASS) {
option value="" selected { "Select a reason" }
@for &(value, label) in DELETION_REASONS {
option value=(value) { (label) }
}
}
(select_chevron())
}
},
))
(text_input("public_reason", "Public Reason (optional)", "", "Terms of service violation"))
(form_field_group("Days Until Deletion", "days_until_deletion", true, None, None, html! {
input type="number" id="days_until_deletion" name="days_until_deletion"
value="14" min="14" required
class="w-full rounded-lg border border-neutral-300 bg-white \
text-neutral-900 text-sm h-8 px-3 py-1.5 \
focus:border-brand-primary focus:outline-none \
focus:ring-2 focus:ring-brand-primary/20";
}))
(form_field_group("Days Until Deletion", "days_until_deletion", true, None,
Some("Moderation reasons are held for at least 60 days. Only User requested allows 14."),
html! {
input type="number" id="days_until_deletion" name="days_until_deletion"
value="60" min="14" max="365" required
class="w-full rounded-lg border border-neutral-300 bg-white \
text-neutral-900 text-sm h-8 px-3 py-1.5 \
focus:border-brand-primary focus:outline-none \
focus:ring-2 focus:ring-brand-primary/20";
},
))
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
(form_actions(html! {
(danger_button("Schedule Deletion"))
@@ -449,6 +466,21 @@ mod tests {
assert!(!markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
}
#[test]
fn deletion_form_has_no_preselected_reason() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
assert!(markup.contains(r#"<option value="" selected>Select a reason</option>"#));
for (value, _) in DELETION_REASONS {
assert!(!markup.contains(&format!(r#"<option value="{value}" selected>"#)));
}
}
#[test]
fn deletion_form_defaults_to_the_moderation_retention_floor() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
}
#[test]
fn remove_grid_can_clear_the_deprecated_clone_features() {
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
@@ -16,7 +16,7 @@ use crate::{
};
use maud::{Markup, html};
const MAX_GIFT_CODES: u32 = 100;
pub const MAX_GIFT_CODES: u32 = 100;
const DEFAULT_GIFT_COUNT: u32 = 10;
pub fn gift_codes_page(
@@ -44,12 +44,12 @@ pub fn gift_codes_page(
(csrf_input(csrf_token))
div class="flex flex-col gap-4" {
(form_field_group(
"Number of codes", "gift-count-slider", false, None,
"Number of codes", "gift-count-slider", true, None,
Some(&format!("Range: 1-{MAX_GIFT_CODES}")),
html! {
input type="number" id="gift-count-slider" name="count"
value=(DEFAULT_GIFT_COUNT) min="1"
max=(MAX_GIFT_CODES)
max=(MAX_GIFT_CODES) required
class=(FORM_INPUT_CLASS);
},
))
@@ -25,7 +25,9 @@ pub fn members_tab(
let total = response.total;
let total_pages = if limit > 0 { total.div_ceil(limit) } else { 1 };
let has_previous = page > 0;
let has_next = (page as u64) < total_pages.saturating_sub(1);
let next_page = page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
html! {
div class="space-y-4" {
@@ -34,12 +36,16 @@ pub fn members_tab(
"Guild Members (" (total) ")"
}
p class="text-sm text-neutral-500" {
@let start = response.offset + 1;
@let end = std::cmp::min(
response.offset + response.members.len() as u64,
total,
);
"Showing " (start) "-" (end) " of " (total)
@if response.members.is_empty() {
"Showing 0 of " (total)
} @else {
@let start = u128::from(response.offset) + 1;
@let end = std::cmp::min(
u128::from(response.offset) + response.members.len() as u128,
u128::from(total),
);
"Showing " (start) "-" (end) " of " (total)
}
}
}
@@ -70,10 +76,10 @@ pub fn members_tab(
}
}
p class="text-sm text-neutral-500" {
"Page " (page + 1) " of " (total_pages)
"Page " (u64::from(page) + 1) " of " (total_pages)
}
@if has_next {
a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (page + 1)}
@if let Some(next_page) = next_page {
a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (next_page)}
class="inline-flex items-center rounded-md bg-brand-primary px-3 \
py-2 text-sm font-medium text-white hover:bg-brand-primary-dark" {
"Next \u{2192}"
@@ -40,13 +40,13 @@ fn channel_type_label(channel_type: i32) -> &'static str {
pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &str) -> Markup {
let base = &config.base_path;
let mut sorted_channels = guild.channels.clone();
let mut sorted_channels: Vec<_> = guild.channels.iter().collect();
sorted_channels.sort_by_key(|c| c.position);
let channels_by_id: std::collections::HashMap<&str, &crate::api::types::GuildChannelSummary> =
guild.channels.iter().map(|c| (c.id.as_str(), c)).collect();
let mut sorted_roles = guild.roles.clone();
let mut sorted_roles: Vec<_> = guild.roles.iter().collect();
sorted_roles.sort_by_key(|role| std::cmp::Reverse(role.position));
let icon_url = guild_icon_url(config, &guild.id, guild.icon.as_deref(), 256, true);
@@ -102,16 +102,10 @@ pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &
p class="text-sm font-semibold text-neutral-500" {
"Custom warning text"
}
@if let Some(ref text) = guild.content_warning_text {
@if !text.trim().is_empty() {
blockquote class="border-amber-300 border-l-2 bg-amber-50 \
px-3 py-2 text-neutral-800 text-sm italic" {
(text)
}
} @else {
p class="text-sm text-neutral-500" {
"\u{2014} (default fallback shown to users)"
}
@if let Some(text) = guild.content_warning_text.as_deref().filter(|text| !text.trim().is_empty()) {
blockquote class="border-amber-300 border-l-2 bg-amber-50 \
px-3 py-2 text-neutral-800 text-sm italic" {
(text)
}
} @else {
p class="text-sm text-neutral-500" {
@@ -17,8 +17,9 @@ pub fn reports_tab(
let base = &config.base_path;
let page_size: u64 = 25;
let has_previous = page > 0;
let has_next = (page as u64) * page_size + reports.len() as u64 > 0
&& (page as u64 + 1) * page_size < total;
let next_page = page.checked_add(1).filter(|next_page| {
(page > 0 || !reports.is_empty()) && u64::from(*next_page) * page_size < total
});
html! {
div class="space-y-4" {
div class="flex items-center justify-between" {
@@ -48,7 +49,7 @@ pub fn reports_tab(
))
}
@if has_previous || has_next {
@if has_previous || next_page.is_some() {
div class="flex justify-center gap-2" {
@if has_previous {
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (page - 1)}
@@ -58,8 +59,8 @@ pub fn reports_tab(
"\u{2190} Newer"
}
}
@if has_next {
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (page + 1)}
@if let Some(next_page) = next_page {
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (next_page)}
class="inline-flex items-center rounded-md border border-neutral-300 \
bg-white px-3 py-2 text-sm font-medium text-neutral-700 \
hover:bg-neutral-50" {
@@ -64,7 +64,6 @@ impl GuildsListParams {
pub struct GuildsListResults<'a> {
pub guilds: Option<&'a [GuildInfo]>,
pub total: Option<u64>,
pub has_more: bool,
}
pub fn guilds_list_page(
@@ -73,14 +72,12 @@ pub fn guilds_list_page(
params: &GuildsListParams,
results: Option<&[GuildInfo]>,
total: Option<u64>,
has_more: bool,
is_htmx: bool,
) -> Markup {
let base = &config.base_path;
let result_state = GuildsListResults {
guilds: results,
total,
has_more,
};
let has_results = results.is_some_and(|guilds| !guilds.is_empty());
let header = html! {
@@ -164,7 +161,10 @@ fn render_results(
fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Markup {
let total_pages = total.div_ceil(u64::from(params.limit)).max(1);
let has_previous = params.page > 0;
let has_next = u64::from(params.page) < total_pages.saturating_sub(1);
let next_page = params
.page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
html! {
div class="mt-6 flex items-center justify-center gap-3" {
@if has_previous {
@@ -181,10 +181,10 @@ fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Ma
}
}
span class="text-neutral-600 text-sm" {
"Page " (params.page + 1) " of " (total_pages)
"Page " (u64::from(params.page) + 1) " of " (total_pages)
}
@if has_next {
a href=(pagination_url(&config.base_path, params, params.page + 1))
@if let Some(next_page) = next_page {
a href=(pagination_url(&config.base_path, params, next_page))
class="rounded-lg bg-neutral-900 px-6 py-2 font-medium text-sm \
text-white no-underline transition-colors hover:bg-neutral-800" {
"Next \u{2192}"
@@ -200,8 +200,7 @@ fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Ma
}
fn pagination_url(base: &str, params: &GuildsListParams, page: u32) -> String {
let mut parts = Vec::new();
parts.push(format!("page={page}"));
let mut parts = vec![format!("page={page}"), format!("limit={}", params.limit)];
if !params.q.is_empty() {
parts.push(format!("q={}", urlencoding::encode(&params.q)));
}
@@ -848,6 +848,18 @@ fn app_public_config_section(
app_public.branding.favicon_url.as_deref().unwrap_or(""),
"https://example.com/favicon.ico",
))
(text_input(
"app_status_page_url",
"Status page URL",
app_public.branding.status_page_url.as_deref().unwrap_or(""),
"https://fluxerstatus.com",
))
(text_input(
"app_status_page_incident_history_url",
"Status page history URL",
app_public.branding.status_page_incident_history_url.as_deref().unwrap_or(""),
"https://fluxerstatus.com/history",
))
}
div class="space-y-2" {
(checkbox(
+2 -13
View File
@@ -7,7 +7,6 @@ use crate::{
templates::{
components::{
auto_refresh::auto_refresh,
badge::{BadgeVariant, badge},
data_field::{data_field_mono, data_field_text, data_grid},
form::{csrf_input, danger_button, form_field_group},
page_container::{page_header_with_actions, page_header_with_back},
@@ -18,17 +17,7 @@ use crate::{
};
use maud::{Markup, html};
fn status_badge(status: &str) -> Markup {
let variant = match status {
"queued" => BadgeVariant::Default,
"running" => BadgeVariant::Info,
"succeeded" => BadgeVariant::Success,
"failed" | "deadletter" => BadgeVariant::Danger,
"cancelled" => BadgeVariant::Warning,
_ => BadgeVariant::Default,
};
badge(status, variant)
}
use super::jobs_list_helpers::status_badge;
fn val_str<'a>(job: &'a serde_json::Value, key: &str) -> &'a str {
job.get(key).and_then(|v| v.as_str()).unwrap_or("")
@@ -71,7 +60,7 @@ fn progress_bar(job: &serde_json::Value) -> Markup {
}
(cur, Some(tot)) => {
let c = cur.unwrap_or(0);
let pct = (c * 100 / tot).min(100);
let pct = (u128::from(c) * 100 / u128::from(tot)).min(100);
html! {
div role="progressbar" aria-valuenow=(pct) aria-valuemin="0"
aria-valuemax="100"
@@ -33,25 +33,26 @@ pub(crate) fn format_progress(job: &serde_json::Value) -> String {
(Some(cur), None | Some(0)) => format!("{cur}"),
(cur, Some(tot)) => {
let c = cur.unwrap_or(0);
let pct = c.saturating_mul(100).checked_div(tot).unwrap_or(0);
let pct = (u128::from(c) * 100)
.checked_div(u128::from(tot))
.unwrap_or(0);
format!("{c} / {tot} ({pct}%)")
}
}
}
pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
let job_id = job
.get("job_id")
.and_then(|v| v.as_str().or_else(|| v.as_u64().map(|_| "")))
.unwrap_or("");
let job_id_display = job
fn job_id_text(value: &serde_json::Value) -> String {
value
.get("job_id")
.map(|v| match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
_ => v.to_string(),
})
.unwrap_or_default();
.unwrap_or_default()
}
pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
let job_id = job_id_text(job);
let task_type = job
.get("task_type")
.and_then(|v| v.as_str())
@@ -79,23 +80,17 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
.unwrap_or_else(|| "cron".to_owned());
let progress = format_progress(job);
let link_id = if job_id.is_empty() {
&job_id_display
} else {
job_id
};
table_row(html! {
(table_cell(true, html! {
span class="whitespace-nowrap text-sm" { (created_at) }
}))
(table_cell(false, html! {
a href={(base) "/jobs/" (link_id)}
a href={(base) "/jobs/" (job_id)}
hx-target="#main-content"
hx-swap="innerHTML"
hx-push-url="true"
class="text-blue-600 text-sm hover:underline" {
(job_id_display)
(job_id)
}
}))
(table_cell(false, html! {
@@ -103,7 +98,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
}))
(table_cell(false, html! { (status_badge(status)) }))
(table_cell(false, html! {
span class="text-sm" data-job-progress=(link_id) { (progress) }
span class="text-sm" data-job-progress=(job_id) { (progress) }
}))
(table_cell(true, html! {
span class="text-sm" { (attempts) "/" (max_attempts) }
@@ -112,7 +107,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
span class="text-sm" { (requester) }
}))
(table_cell(false, html! {
a href={(base) "/jobs/" (link_id)}
a href={(base) "/jobs/" (job_id)}
hx-target="#main-content"
hx-swap="innerHTML"
hx-push-url="true"
@@ -159,14 +154,7 @@ pub(crate) fn next_page_link(
.get("created_at")
.and_then(|v| v.as_str())
.unwrap_or("");
let job_id = cursor
.get("job_id")
.map(|v| match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
_ => v.to_string(),
})
.unwrap_or_default();
let job_id = job_id_text(cursor);
let mut params = vec![
format!("cursor_bucket_day={bucket_day}"),
@@ -466,7 +466,10 @@ fn reports_pagination(
limit: u32,
total: u64,
) -> Markup {
let total_pages = ((total + u64::from(limit).saturating_sub(1)) / u64::from(limit)).max(1);
let total_pages = total.div_ceil(u64::from(limit)).max(1);
let next_page = page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
html! {
div class="mt-4 flex items-center justify-between" {
@if page > 0 {
@@ -478,10 +481,10 @@ fn reports_pagination(
span {}
}
span class="text-neutral-500 text-sm" {
"Page " (page + 1) " of " (total_pages)
"Page " (u64::from(page) + 1) " of " (total_pages)
}
@if u64::from(page + 1) < total_pages {
a href=(reports_url(config, filters, page + 1, limit))
@if let Some(next_page) = next_page {
a href=(reports_url(config, filters, next_page, limit))
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
(PreEscaped("Next &rarr;"))
}
@@ -121,7 +121,7 @@ fn status_section(base: &str, rs: &RefreshStatus) -> Markup {
@if is_in_progress {
@if let (Some(idx), Some(tot)) = (rs.indexed, rs.total) {
@let pct = idx.saturating_mul(100).checked_div(tot).unwrap_or(0);
@let pct = (u128::from(idx) * 100).checked_div(u128::from(tot)).unwrap_or(0);
div class="space-y-2" {
div class="flex items-center justify-between" {
p class="text-sm text-neutral-700" {
@@ -146,7 +146,7 @@ fn deletion_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
(csrf_input(csrf_token))
div class="space-y-3" {
(form_label("Days until deletion"))
input type="number" name="days" value="60"
input type="number" name="days_until_deletion" value="60"
min="60" max="365"
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
@@ -322,9 +322,8 @@ fn message_shred_status_content(status: &serde_json::Value) -> Markup {
let total = value_u64(status, "total").unwrap_or(0);
let processed = value_u64(status, "processed").unwrap_or(0);
let skipped = value_u64(status, "skipped").unwrap_or(0);
let percentage = processed
.saturating_mul(100)
.checked_div(total)
let percentage = (u128::from(processed) * 100)
.checked_div(u128::from(total))
.unwrap_or(0)
.min(100);
html! {
@@ -78,9 +78,11 @@ fn report_section(
}
};
let offset = current_page as u64 * limit as u64;
let offset = u64::from(current_page) * u64::from(limit);
let has_previous = current_page > 0;
let has_next = offset + (reports.len() as u64) < total;
let next_page = current_page
.checked_add(1)
.filter(|_| (reports.len() as u64) < total.saturating_sub(offset));
let (sent_page, received_page) = if kind == "sent" {
(current_page, other_page)
@@ -115,7 +117,7 @@ fn report_section(
))
}
@if has_previous || has_next {
@if has_previous || next_page.is_some() {
div class="flex justify-center gap-2" {
@if has_previous {
a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { current_page - 1 } else { sent_page }) "&reports_received_page=" (if kind == "received" { current_page - 1 } else { received_page })}
@@ -125,8 +127,8 @@ fn report_section(
"Previous"
}
}
@if has_next {
a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { current_page + 1 } else { sent_page }) "&reports_received_page=" (if kind == "received" { current_page + 1 } else { received_page })}
@if let Some(next_page) = next_page {
a href={(base) "/users/" (user_id) "?tab=reports&reports_limit=" (limit) "&reports_sent_page=" (if kind == "sent" { next_page } else { sent_page }) "&reports_received_page=" (if kind == "received" { next_page } else { received_page })}
class="inline-flex items-center rounded-md border \
border-neutral-300 bg-white px-3 py-2 text-sm \
font-medium text-neutral-700 hover:bg-neutral-50" {
@@ -393,6 +393,7 @@ fn render_users_table(config: &AdminConfig, users: &[AdminUser], can_view_email:
}
fn pagination_controls(base: &str, params: &UserListParams, has_more: bool) -> Markup {
let next_page = params.page.checked_add(1).filter(|_| has_more);
html! {
div class="mt-4 flex items-center justify-between" {
@if params.page > 0 {
@@ -403,8 +404,8 @@ fn pagination_controls(base: &str, params: &UserListParams, has_more: bool) -> M
} @else {
span {}
}
@if has_more {
a href=(users_url(base, params, params.page + 1))
@if let Some(next_page) = next_page {
a href=(users_url(base, params, next_page))
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
"Next >"
}
+25 -8
View File
@@ -17,11 +17,14 @@ pub fn clean_string(value: &str) -> Option<String> {
}
pub fn parse_comma_separated(value: &str) -> Vec<String> {
comma_separated_values(value).map(str::to_owned).collect()
}
fn comma_separated_values(value: &str) -> impl Iterator<Item = &str> {
value
.split([',', '\n', '\r'])
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect()
.map(str::trim)
.filter(|value| !value.is_empty())
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
@@ -63,12 +66,18 @@ impl MultiValueForm {
self.first(key).and_then(clean_string)
}
pub fn parse_i32(&self, key: &str) -> Option<i32> {
self.first(key).and_then(|value| value.parse().ok())
pub fn parse_value<T: std::str::FromStr>(&self, key: &str) -> Result<Option<T>, T::Err> {
self.parse_value_any(&[key])
}
pub fn parse_i64(&self, key: &str) -> Option<i64> {
self.first(key).and_then(|value| value.parse().ok())
pub fn parse_value_any<T: std::str::FromStr>(
&self,
keys: &[&str],
) -> Result<Option<T>, T::Err> {
keys.iter()
.find_map(|key| self.first(key))
.map(str::parse)
.transpose()
}
pub fn parse_u32(&self, key: &str) -> Option<u32> {
@@ -92,13 +101,21 @@ impl MultiValueForm {
.get(key)
.into_iter()
.flat_map(|values| values.iter())
.flat_map(|value| parse_comma_separated(value))
.flat_map(|value| comma_separated_values(value))
.map(str::to_owned)
.collect()
}
pub fn list_values_any(&self, keys: &[&str]) -> Vec<String> {
keys.iter().flat_map(|key| self.list_values(key)).collect()
}
pub fn parse_list_values<T: std::str::FromStr>(&self, keys: &[&str]) -> Result<Vec<T>, T::Err> {
self.list_values_any(keys)
.iter()
.map(|value| value.parse())
.collect()
}
}
pub fn parse_page(value: Option<&str>) -> u32 {
+323
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_admin::api::generated::types as generated_types;
use fluxer_admin::api::types;
#[test]
@@ -229,6 +230,7 @@ fn deserialize_audit_logs_response() {
"target_type": "user",
"target_id": "1130958221824557056",
"action": "list_user_sessions",
"access": "read",
"audit_log_reason": null,
"metadata": {"session_count": "3"},
"created_at": "2026-05-26T13:21:47.138Z"
@@ -242,11 +244,332 @@ fn deserialize_audit_logs_response() {
assert_eq!(resp.logs.len(), 1);
assert_eq!(resp.logs[0].log_id, "1508822460457747580");
assert_eq!(resp.logs[0].action, "list_user_sessions");
assert_eq!(resp.logs[0].access.as_deref(), Some("read"));
assert_eq!(resp.logs[0].target_type, "user");
assert!(resp.logs[0].audit_log_reason.is_none());
assert_eq!(resp.logs[0].metadata.get("session_count").unwrap(), "3");
}
#[test]
fn deserialize_guild_audit_logs_with_unknown_keys() {
let json = r#"{
"audit_log_entries": [
{
"id": "1508822460457747581",
"action_type": 22,
"user_id": "1130650140672000000",
"target_id": "1130958221824557056",
"reason": "spam",
"options": {
"delete_message_seconds": 3600,
"future_option": {"nested": true}
},
"changes": [
{"key": "future_change", "future_field": 1}
],
"future_entry_field": "ignored"
},
{
"id": "1508822460457747580",
"action_type": 31,
"user_id": "1130650140672000000",
"target_id": "1130958221824557057",
"options": {
"id": "1130958221824557057",
"type": 0,
"channel_id": "1130958221824557058",
"role_name": "Moderators"
},
"changes": [
{"key": "permissions_diff", "new_value": {"added": ["SEND_MESSAGES"], "removed": []}}
]
},
{
"id": "1508822460457747579",
"action_type": 11,
"user_id": "1130650140672000000",
"target_id": "1130958221824557059",
"options": {"type": 998},
"changes": [
{"key": "name", "old_value": "old-name", "new_value": "new-name"},
{"key": "position", "old_value": 3, "new_value": 4},
{"key": "nsfw", "new_value": true},
{"key": "roles", "new_value": ["1130958221824557060", "1130958221824557061"]}
]
}
],
"users": [],
"webhooks": []
}"#;
let resp: generated_types::ListGuildAuditLogsResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.audit_log_entries.len(), 3);
let ban = &resp.audit_log_entries[0];
assert_eq!(*ban.action_type, 22);
assert_eq!(ban.reason.as_deref(), Some("spam"));
assert!(ban.options.is_some());
assert_eq!(ban.changes.len(), 1);
assert_eq!(ban.changes[0].key, "future_change");
assert!(ban.changes[0].new_value.is_none());
let role_update = &resp.audit_log_entries[1];
let options = role_update.options.as_ref().unwrap();
assert_eq!(options.role_name.as_deref(), Some("Moderators"));
assert_eq!(options.channel_id.as_deref(), Some("1130958221824557058"));
assert_eq!(options.type_, Some(0.0));
assert_eq!(role_update.changes[0].key, "permissions_diff");
assert!(role_update.changes[0].new_value.is_some());
let channel_update = &resp.audit_log_entries[2];
assert_eq!(channel_update.changes.len(), 4);
assert_eq!(
channel_update.changes[0]
.old_value
.as_ref()
.and_then(|value| value.as_str()),
Some("old-name")
);
assert_eq!(
channel_update.changes[0]
.new_value
.as_ref()
.and_then(|value| value.as_str()),
Some("new-name")
);
assert_eq!(
channel_update.changes[1]
.new_value
.as_ref()
.and_then(|value| value.as_i64()),
Some(4)
);
assert_eq!(
channel_update.changes[2]
.new_value
.as_ref()
.and_then(|value| value.as_bool()),
Some(true)
);
assert_eq!(
channel_update.changes[3]
.new_value
.as_ref()
.and_then(|value| value.as_array())
.map(|value| value.len()),
Some(2)
);
}
#[test]
fn deserialize_instance_config_response_with_unknown_keys() {
let json = r##"{
"sso": {
"enabled": true,
"enforced": false,
"display_name": "Fluxer SSO",
"issuer": "https://id.example.com",
"authorization_url": "https://id.example.com/authorize",
"token_url": "https://id.example.com/token",
"userinfo_url": "https://id.example.com/userinfo",
"jwks_url": "https://id.example.com/jwks.json",
"client_id": "fluxer-admin",
"client_secret_set": true,
"scope": "openid profile email",
"allowed_domains": ["fluxer.com"],
"auto_provision": true,
"redirect_uri": "https://fluxer.com/sso/callback"
},
"gateway_rollout": {
"session_rollout_percentage": 100,
"session_rollout_mode": "modulo",
"guild_rollout_percentage": 100,
"rpc_request_timeout_ms": 10000,
"max_concurrent_session_starts": 512,
"max_concurrent_guild_starts": 256,
"gateway_dispatch_relay_shards": 32,
"gateway_dispatch_relay_max_queue": 50000,
"voice_e2ee_scope": "guild_feature_only",
"future_rollout_knob": 3
},
"voice_noise_suppression": {
"enabled": true,
"config_version": 4,
"default_backend": "rnnoise",
"enabled_backends": ["none", "standard", "rnnoise"],
"allow_user_override": true,
"rollout_basis_points": 10000,
"rollout_salt": "voice-ns-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"stereo_enabled": false,
"suppression_strength": 80,
"future_presentation_knob": "verbose",
"future_knob": 7,
"future_object_knob": {"nested": true},
"future_list_knob": ["a", "b"]
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
"mode": "open",
"admin_registration_urls_enabled": false,
"urls": [],
"pending_registrations": []
},
"self_hosted": false,
"app_public": {
"branding": {
"product_name": "Fluxer",
"icon_url": "https://cdn.example.com/icon.png",
"symbol_url": "https://cdn.example.com/symbol.svg",
"logo_url": "https://cdn.example.com/logo.svg",
"wordmark_url": "https://cdn.example.com/wordmark.svg",
"favicon_url": "https://cdn.example.com/favicon.ico",
"theme_color": "#5865f2",
"future_asset_url": "https://cdn.example.com/future.png"
},
"setup": {"configured": true},
"legal": {
"terms_url": "https://fluxer.com/terms",
"privacy_url": "https://fluxer.com/privacy"
},
"registration": {"collect_date_of_birth": true}
},
"policy": {
"single_community_enabled": false,
"single_community_guild_id": null,
"direct_messages_disabled": false,
"direct_messages_locked": false,
"premium_mode": "mirror",
"services": {
"gif_enabled": true,
"youtube_enabled": true,
"bluesky_enabled": false,
"future_service_enabled": true
},
"services_resolved": {
"gif_enabled": true,
"youtube_enabled": true,
"bluesky_enabled": false
},
"services_available": {"gif": true, "youtube": true, "bluesky": false},
"deferred_phone_gate": {
"enabled": false,
"window_hours": 24,
"member_threshold": 100
}
},
"integrations": {
"gif": {"klipy_api_key_set": true, "effective_available": true},
"youtube": {"api_key_set": true, "effective_available": true},
"captcha": {
"provider": "hcaptcha",
"effective_provider": "hcaptcha",
"hcaptcha_site_key": "site",
"hcaptcha_secret_key_set": true,
"turnstile_site_key": "",
"turnstile_secret_key_set": false,
"effective_enabled": true
},
"email": {
"enabled": true,
"effective_enabled": true,
"provider": "smtp",
"effective_provider": "smtp",
"from_email": "[email protected]",
"from_name": "Fluxer",
"smtp": {
"host": "smtp.example.com",
"port": 587,
"username": "fluxer",
"password_set": true,
"secure": true
},
"disable_new_ip_authorization": false,
"effective_disable_new_ip_authorization": false
},
"bluesky": {
"enabled": false,
"effective_enabled": false,
"client_name": "Fluxer",
"client_uri": "https://fluxer.com",
"logo_uri": "https://cdn.example.com/logo.svg",
"tos_uri": "https://fluxer.com/terms",
"policy_uri": "https://fluxer.com/privacy",
"key_count": 0
}
},
"media": {
"attachment_decay": {
"enabled": true,
"min_size_mb": 10,
"max_size_mb": 500,
"max_eligible_size_mb": 500,
"min_lifetime_days": 30,
"max_lifetime_days": 365,
"curve": 2,
"renew_threshold_days": 7,
"renew_window_days": 14,
"effective": {
"enabled": true,
"min_size_mb": 10,
"max_size_mb": 500,
"max_eligible_size_mb": 500,
"min_lifetime_days": 30,
"max_lifetime_days": 365,
"curve": 2,
"renew_threshold_days": 7,
"renew_window_days": 14,
"future_curve": 1.5
}
}
},
"future_section": {"enabled": true, "rollout_basis_points": 10000},
"future_flag": 3
}"##;
let resp: generated_types::InstanceConfigResponse = serde_json::from_str(json).expect(
"the build.rs relaxation must keep unknown response keys from failing the whole response",
);
assert!(!resp.self_hosted);
assert!(resp.voice_noise_suppression.enabled);
assert_eq!(resp.voice_noise_suppression.config_version, 4);
assert_eq!(resp.voice_noise_suppression.rollout_basis_points, 10000);
assert_eq!(*resp.voice_noise_suppression.rollout_salt, "voice-ns-v1");
assert_eq!(resp.voice_noise_suppression.enabled_backends.len(), 3);
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
assert!(resp.policy.single_community_guild_id.is_none());
assert_eq!(resp.policy.services.gif_enabled, Some(true));
assert_eq!(resp.app_public.branding.product_name, "Fluxer");
assert!(resp.media.attachment_decay.effective.enabled);
let without_unknown_keys = json
.replace("\"future_rollout_knob\": 3,", "")
.replace("\"future_presentation_knob\": \"verbose\",", "")
.replace("\"future_knob\": 7,", "")
.replace("\"future_object_knob\": {\"nested\": true},", "")
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
.replace(
"\"future_asset_url\": \"https://cdn.example.com/future.png\",",
"",
)
.replace("\"future_service_enabled\": true,", "")
.replace("\"future_curve\": 1.5,", "")
.replace(
"\"future_section\": {\"enabled\": true, \"rollout_basis_points\": 10000},",
"",
)
.replace("\"future_flag\": 3,", "");
let baseline: generated_types::InstanceConfigResponse =
serde_json::from_str(&without_unknown_keys).expect("known keys alone still deserialize");
assert_eq!(
serde_json::to_value(&baseline).unwrap(),
serde_json::to_value(&resp).unwrap()
);
}
#[test]
fn deserialize_search_reports_response() {
let json = r#"{
+78
View File
@@ -168,6 +168,39 @@ async fn detail_tab_routes_return_layout_or_fragments_by_route_shape() {
}
}
#[tokio::test]
async fn target_audit_log_tabs_request_write_entries_only() {
let app = setup().await;
for path in [
"/users/1500000000000000001/tabs/audit_logs",
"/guilds/1600000000000000001/tabs/audit_logs",
] {
let fragment = get(&app, path, &[]).await;
assert!(fragment.contains("Temp ban"), "{path}\n{fragment}");
assert!(!fragment.contains("Get user"), "{path}\n{fragment}");
}
}
#[tokio::test]
async fn audit_log_page_forwards_the_access_filter() {
let app = setup().await;
let all = get(&app, "/audit-logs", &[]).await;
assert!(all.contains("Temp ban"), "{all}");
assert!(all.contains("Get user"), "{all}");
assert!(
all.contains(r#"<option value="" selected>All entries</option>"#),
"{all}"
);
let reads = get(&app, "/audit-logs?access=read", &[]).await;
assert!(reads.contains("Get user"), "{reads}");
assert!(!reads.contains("Temp ban"), "{reads}");
assert!(
reads.contains(r#"<option value="read" selected>Reads only</option>"#),
"{reads}"
);
}
#[tokio::test]
async fn report_routes_keep_layout_and_fragment_contract() {
let app = setup().await;
@@ -844,6 +877,25 @@ async fn mock_api(method: Method, uri: Uri) -> Response {
json_response(instance_config_without_pending_registrations())
}
(Method::GET, "/admin/limit-config") => json_response(limit_config()),
(Method::GET, "/admin/audit-logs") => {
let access = uri.query().and_then(|query| {
url::form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == "access")
.map(|(_, value)| value.into_owned())
});
let logs = [
audit_log_entry("1900000000000000101", "get_user", "read"),
audit_log_entry("1900000000000000102", "temp_ban", "write"),
]
.into_iter()
.filter(|entry| {
access
.as_deref()
.is_none_or(|access| entry.access.to_string() == access)
})
.collect::<Vec<_>>();
json_response(json!({ "total": logs.len(), "logs": logs }))
}
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
}
}
@@ -975,6 +1027,32 @@ fn guild_fixtures_match_generated_response_contracts() {
assert_eq!(detail.member_count, 12);
}
fn audit_log_entry(
log_id: &str,
action: &str,
access: &str,
) -> generated_types::AdminAuditLogResponseSchema {
serde_json::from_value(json!({
"log_id": log_id,
"admin_user_id": "1500000000000000000",
"admin_user": null,
"target_type": "user",
"target_id": "1500000000000000001",
"target_user": null,
"target_guild": null,
"target_channel": null,
"related_users": {},
"related_guilds": {},
"related_channels": {},
"action": action,
"access": access,
"audit_log_reason": null,
"metadata": {},
"created_at": "2026-09-16T12:00:00.000Z"
}))
.expect("audit log fixture must match the generated response contract")
}
fn searched_application() -> Value {
json!({
"id": "1700000000000000001",
-1
View File
@@ -41,7 +41,6 @@
"@pkgs/email": "workspace:*",
"@pkgs/geoip": "workspace:*",
"@pkgs/http_client": "workspace:*",
"@pkgs/initialization": "workspace:*",
"@pkgs/kv_client": "workspace:*",
"@pkgs/locale": "workspace:*",
"@pkgs/media_proxy_utils": "workspace:*",
+2 -6
View File
@@ -6,12 +6,8 @@ import type {CacheLookupResult} from '@pkgs/cache/src/ICacheService';
export function parseCachedValue<T>(value: string, logger?: CacheLogger): CacheLookupResult<T> {
try {
return {hit: true, value: JSON.parse(value)};
} catch (error) {
if (logger) {
const truncatedValue = value.length > 200 ? `${value.substring(0, 200)}...` : value;
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({errorMessage, value: truncatedValue}, '[CacheProvider] JSON parse error');
}
} catch {
logger?.error({valueLength: value.length}, '[CacheProvider] JSON parse error');
return {hit: false};
}
}
+103 -86
View File
@@ -8,27 +8,34 @@ import {
} from '@pkgs/cache/src/CacheLockValidation';
import {type CacheLookupResult, ICacheService} from '@pkgs/cache/src/ICacheService';
interface CacheEntry<T> {
value: T;
interface CacheEntry {
expiresAt?: number;
}
interface CacheValueEntry extends CacheEntry {
kind: 'value';
value: unknown;
}
interface CacheSetEntry extends CacheEntry {
kind: 'set';
value: Set<string>;
}
interface CacheLockEntry extends CacheEntry {
token: string;
expiresAt: number;
}
interface InMemoryProviderConfig {
maxSize?: number;
cleanupIntervalMs?: number;
}
export class InMemoryProvider extends ICacheService {
private cache = new Map<string, CacheEntry<unknown>>();
private sets = new Map<string, Set<string>>();
private locks = new Map<
string,
{
token: string;
expiresAt: number;
}
>();
private maxSize: number;
private readonly cache = new Map<string, CacheValueEntry | CacheSetEntry>();
private readonly locks = new Map<string, CacheLockEntry>();
private readonly maxSize: number;
private cleanupInterval?: NodeJS.Timeout;
constructor(config: InMemoryProviderConfig = {}) {
@@ -42,20 +49,53 @@ export class InMemoryProvider extends ICacheService {
private cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (entry.expiresAt && entry.expiresAt <= now) {
if (this.isExpired(entry, now)) {
this.cache.delete(key);
}
}
for (const [key, lock] of this.locks.entries()) {
if (lock.expiresAt <= now) {
if (this.isExpired(lock, now)) {
this.locks.delete(key);
}
}
}
private isExpired(entry: CacheEntry<unknown>): boolean {
if (!entry.expiresAt) return false;
return Date.now() >= entry.expiresAt;
private isExpired(entry: CacheEntry, now = Date.now()): boolean {
return entry.expiresAt !== undefined && now >= entry.expiresAt;
}
private getLiveEntry(key: string): CacheValueEntry | CacheSetEntry | undefined {
const entry = this.cache.get(key);
if (entry && this.isExpired(entry)) {
this.cache.delete(key);
return undefined;
}
return entry;
}
private getValueEntry(key: string): CacheValueEntry | undefined {
const entry = this.getLiveEntry(key);
if (entry && entry.kind !== 'value') {
throw new Error('Cache key contains a set, not a value.');
}
return entry;
}
private getSetEntry(key: string): CacheSetEntry | undefined {
const entry = this.getLiveEntry(key);
if (entry && entry.kind !== 'set') {
throw new Error('Cache key contains a value, not a set.');
}
return entry;
}
private getLiveLock(key: string): CacheLockEntry | undefined {
const lock = this.locks.get(key);
if (lock && this.isExpired(lock)) {
this.locks.delete(key);
return undefined;
}
return lock;
}
private evictIfNeeded(): void {
@@ -68,18 +108,16 @@ export class InMemoryProvider extends ICacheService {
}
async getEntry<T>(key: string): Promise<CacheLookupResult<T>> {
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
if (!entry) return {hit: false};
if (this.isExpired(entry)) {
this.cache.delete(key);
return {hit: false};
}
return {hit: true, value: entry.value};
const entry = this.getValueEntry(key);
return entry ? {hit: true, value: entry.value as T} : {hit: false};
}
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
this.evictIfNeeded();
const entry: CacheEntry<T> = {
if (!this.cache.has(key)) {
this.evictIfNeeded();
}
const entry: CacheValueEntry = {
kind: 'value',
value,
expiresAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined,
};
@@ -91,36 +129,28 @@ export class InMemoryProvider extends ICacheService {
}
async getAndDelete<T>(key: string): Promise<T | null> {
const value = await this.get<T>(key);
if (value !== null) {
this.cache.delete(key);
}
return value;
const entry = this.getValueEntry(key);
this.cache.delete(key);
return entry ? (entry.value as T) : null;
}
async exists(key: string): Promise<boolean> {
const entry = this.cache.get(key);
if (!entry) return false;
if (this.isExpired(entry)) {
this.cache.delete(key);
return false;
}
return true;
return this.getLiveEntry(key) !== undefined;
}
async expire(key: string, ttlSeconds: number): Promise<void> {
const entry = this.cache.get(key);
if (entry && !this.isExpired(entry)) {
const entry = this.getLiveEntry(key);
if (entry) {
entry.expiresAt = Date.now() + ttlSeconds * 1000;
}
}
async ttl(key: string): Promise<number> {
const entry = this.cache.get(key);
if (!entry || this.isExpired(entry)) {
const entry = this.getLiveEntry(key);
if (!entry) {
return -2;
}
if (!entry.expiresAt) {
if (entry.expiresAt === undefined) {
return -1;
}
const ttlMs = entry.expiresAt - Date.now();
@@ -128,11 +158,10 @@ export class InMemoryProvider extends ICacheService {
}
async mget<T>(keys: Array<string>): Promise<Array<T | null>> {
const results: Array<T | null> = [];
for (const key of keys) {
results.push(await this.get<T>(key));
}
return results;
return keys.map((key) => {
const entry = this.getLiveEntry(key);
return entry?.kind === 'value' ? (entry.value as T) : null;
});
}
async mset<T>(
@@ -162,8 +191,7 @@ export class InMemoryProvider extends ICacheService {
async acquireLock(key: string, ttlSeconds: number): Promise<string | null> {
validateLockKey(key);
const lockKey = formatLockKey(key);
const existingLock = this.locks.get(lockKey);
if (existingLock && existingLock.expiresAt > Date.now()) {
if (this.getLiveLock(lockKey)) {
return null;
}
const token = generateLockToken();
@@ -178,7 +206,7 @@ export class InMemoryProvider extends ICacheService {
validateLockKey(key);
validateLockToken(token);
const lockKey = formatLockKey(key);
const lock = this.locks.get(lockKey);
const lock = this.getLiveLock(lockKey);
if (!lock || lock.token !== token) {
return false;
}
@@ -190,23 +218,21 @@ export class InMemoryProvider extends ICacheService {
validateLockKey(key);
validateLockToken(token);
const lockKey = formatLockKey(key);
const lock = this.locks.get(lockKey);
if (!lock || lock.token !== token || lock.expiresAt <= Date.now()) {
const lock = this.getLiveLock(lockKey);
if (!lock || lock.token !== token) {
return false;
}
this.locks.set(lockKey, {
token,
expiresAt: Date.now() + ttlSeconds * 1000,
});
lock.expiresAt = Date.now() + ttlSeconds * 1000;
return true;
}
async getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null> {
const value = await this.get<T>(key);
if (value !== null) {
await this.expire(key, newTtlSeconds);
const entry = this.getValueEntry(key);
if (!entry) {
return null;
}
return value;
entry.expiresAt = Date.now() + newTtlSeconds * 1000;
return entry.value as T;
}
async publish(_channel: string, _message: string): Promise<void> {
@@ -214,43 +240,35 @@ export class InMemoryProvider extends ICacheService {
}
async sadd(key: string, member: string, ttlSeconds?: number): Promise<void> {
let set = this.sets.get(key);
if (!set) {
set = new Set<string>();
this.sets.set(key, set);
let entry = this.getSetEntry(key);
if (!entry) {
this.evictIfNeeded();
entry = {kind: 'set', value: new Set<string>()};
this.cache.set(key, entry);
}
set.add(member);
entry.value.add(member);
if (ttlSeconds) {
await this.set(`${key}:expiry`, {}, ttlSeconds);
entry.expiresAt = Date.now() + ttlSeconds * 1000;
}
}
async srem(key: string, member: string): Promise<void> {
const set = this.sets.get(key);
if (set) {
set.delete(member);
if (set.size === 0) {
this.sets.delete(key);
}
const entry = this.getSetEntry(key);
if (!entry) {
return;
}
entry.value.delete(member);
if (entry.value.size === 0) {
this.cache.delete(key);
}
}
async smembers(key: string): Promise<Set<string>> {
const expiryExists = await this.exists(`${key}:expiry`);
if (!expiryExists && this.sets.has(key)) {
return new Set();
}
return this.sets.get(key) ?? new Set<string>();
return new Set(this.getSetEntry(key)?.value);
}
async sismember(key: string, member: string): Promise<boolean> {
const set = this.sets.get(key);
if (!set) return false;
const expiryExists = await this.exists(`${key}:expiry`);
if (!expiryExists) {
return false;
}
return set.has(member);
return this.getSetEntry(key)?.value.has(member) ?? false;
}
destroy(): void {
@@ -259,7 +277,6 @@ export class InMemoryProvider extends ICacheService {
this.cleanupInterval = undefined;
}
this.cache.clear();
this.sets.clear();
this.locks.clear();
}
}
+11 -7
View File
@@ -10,7 +10,7 @@ import {
import type {CacheLogger, CacheTelemetry} from '@pkgs/cache/src/CacheProviderTypes';
import {parseCachedValue, safeJsonParse, serializeValue} from '@pkgs/cache/src/CacheSerialization';
import {type CacheLookupResult, ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {runSlotBatches, splitIntoSlotBatches} from '@pkgs/kv_client/src/KVHashSlots';
interface KVCacheProviderConfig {
@@ -70,6 +70,14 @@ export class KVCacheProvider extends ICacheService {
}
}
private async executePipeline(pipeline: IKVPipeline): Promise<void> {
for (const [error] of await pipeline.exec()) {
if (error) {
throw error;
}
}
}
async getEntry<T>(key: string): Promise<CacheLookupResult<T>> {
return this.instrumented(
'get',
@@ -153,11 +161,7 @@ export class KVCacheProvider extends ICacheService {
pipeline.set(entry.key, entry.value);
}
}
for (const [error] of await pipeline.exec()) {
if (error) {
throw error;
}
}
await this.executePipeline(pipeline);
});
}
@@ -204,7 +208,7 @@ export class KVCacheProvider extends ICacheService {
if (ttlSeconds) {
pipeline.expire(key, ttlSeconds);
}
await pipeline.exec();
await this.executePipeline(pipeline);
}
async srem(key: string, member: string): Promise<void> {
@@ -20,6 +20,21 @@ interface CaptchaVerifyResponse {
'error-codes'?: Array<string>;
hostname?: string;
challenge_ts?: string;
score?: number;
}
function isCaptchaVerifyResponse(value: unknown): value is CaptchaVerifyResponse {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const data = value as Record<string, unknown>;
const errorCodes = data['error-codes'];
return (
typeof data.success === 'boolean' &&
(errorCodes === undefined || (Array.isArray(errorCodes) && errorCodes.every((code) => typeof code === 'string'))) &&
(data.hostname === undefined || typeof data.hostname === 'string') &&
(data.challenge_ts === undefined || typeof data.challenge_ts === 'string') &&
(data.score === undefined ||
(typeof data.score === 'number' && Number.isFinite(data.score) && data.score >= 0 && data.score <= 1))
);
}
export abstract class HttpCaptchaProvider implements ICaptchaProvider {
@@ -58,10 +73,17 @@ export abstract class HttpCaptchaProvider implements ICaptchaProvider {
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok) {
await response.body?.cancel().catch(() => {
this.logger?.warn({status: response.status}, `${this.providerName} failed to cancel discarded response body`);
});
this.logger?.error({status: response.status}, `${this.providerName} verify request failed`);
return false;
}
const data = (await response.json()) as CaptchaVerifyResponse;
const data: unknown = await response.json();
if (!isCaptchaVerifyResponse(data)) {
this.logger?.error({}, `${this.providerName} returned an invalid verification response`);
return false;
}
if (!data.success) {
this.logger?.warn({errorCodes: data['error-codes']}, `${this.providerName} verification failed`);
return false;
@@ -3,11 +3,11 @@
import type {Client} from '@elastic/elasticsearch';
import type {SortCombinations} from '@elastic/elasticsearch/lib/api/types';
import type {AuditLogSearchFilters, SearchableAuditLog} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
import {compactFilters, esTermFilter} from '../ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '../ElasticsearchIndexDefinitions';
import type {ElasticsearchDistributedLock} from './ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from './ElasticsearchIndexAdapter';
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {compactFilters, esTermFilter, esTermsFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
function buildAuditLogFilters(filters: AuditLogSearchFilters): Array<ElasticsearchFilter | undefined> {
const clauses: Array<ElasticsearchFilter | undefined> = [];
@@ -15,6 +15,10 @@ function buildAuditLogFilters(filters: AuditLogSearchFilters): Array<Elasticsear
if (filters.targetType) clauses.push(esTermFilter('targetType', filters.targetType));
if (filters.targetId) clauses.push(esTermFilter('targetId', filters.targetId));
if (filters.action) clauses.push(esTermFilter('action', filters.action));
if (filters.actions && filters.actions.length > 0) clauses.push(esTermsFilter('action.keyword', filters.actions));
if (filters.excludeActions && filters.excludeActions.length > 0) {
clauses.push({bool: {must_not: [esTermsFilter('action.keyword', filters.excludeActions)]}});
}
return compactFilters(clauses);
}
@@ -3,11 +3,11 @@
import type {Client} from '@elastic/elasticsearch';
import type {SortCombinations} from '@elastic/elasticsearch/lib/api/types';
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
import {compactFilters, esAndTerms, esTermFilter} from '../ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '../ElasticsearchIndexDefinitions';
import type {ElasticsearchDistributedLock} from './ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from './ElasticsearchIndexAdapter';
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {compactFilters, esAndTerms, esTermFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
function buildGuildFilters(filters: GuildSearchFilters): Array<ElasticsearchFilter | undefined> {
const clauses: Array<ElasticsearchFilter | undefined> = [];
@@ -6,11 +6,17 @@ import type {
GuildMemberSearchFilters,
SearchableGuildMember,
} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
import {compactFilters, esAndTerms, esRangeFilter, esTermFilter, esTermsFilter} from '../ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '../ElasticsearchIndexDefinitions';
import type {ElasticsearchDistributedLock} from './ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from './ElasticsearchIndexAdapter';
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {
compactFilters,
esAndTerms,
esRangeFilter,
esTermFilter,
esTermsFilter,
} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
function buildGuildMemberFilters(filters: GuildMemberSearchFilters): Array<ElasticsearchFilter | undefined> {
const clauses: Array<ElasticsearchFilter | undefined> = [];
@@ -1,16 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Client} from '@elastic/elasticsearch';
import type {SearchRequest, SortCombinations, SortResults} from '@elastic/elasticsearch/lib/api/types';
import type {
BulkOperationType,
BulkResponse,
DeleteByQueryResponse,
ErrorCause,
SearchRequest,
SortCombinations,
SortResults,
} from '@elastic/elasticsearch/lib/api/types';
import type {ISearchAdapter, SearchOptions, SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
import {compactFilters} from '../ElasticsearchFilterUtils';
import type {ElasticsearchIndexDefinition} from '../ElasticsearchIndexDefinitions';
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {compactFilters} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import type {ElasticsearchIndexDefinition} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
const ELASTICSEARCH_MAX_RESULT_WINDOW = 10000;
const DEEP_PAGINATION_BATCH_SIZE = 1000;
const MAX_SEARCH_LIMIT = 1000;
const FACET_TERM_LIMIT = 200;
const ERROR_TYPE_MAX_LENGTH = 128;
interface ElasticsearchSearchHit<TResult> {
_source?: TResult;
@@ -191,7 +200,8 @@ export class ElasticsearchIndexAdapter<
}
this.assertInitialised();
const operations = docs.flatMap((doc) => [{index: {_index: this.indexDefinition.indexName, _id: doc.id}}, doc]);
await this.client.bulk({operations, refresh: false});
const response = await this.client.bulk({operations, refresh: false});
this.assertBulkSucceeded(response, 'index');
}
async updateDocument(doc: TResult): Promise<void> {
@@ -205,12 +215,7 @@ export class ElasticsearchIndexAdapter<
}
async bulkIndexDocuments(docs: Array<TResult>): Promise<void> {
if (docs.length === 0) {
return;
}
this.assertInitialised();
const operations = docs.flatMap((doc) => [{index: {_index: this.indexDefinition.indexName, _id: doc.id}}, doc]);
await this.client.bulk({operations, refresh: false});
await this.indexDocuments(docs);
}
async refreshIndex(): Promise<void> {
@@ -228,25 +233,28 @@ export class ElasticsearchIndexAdapter<
}
this.assertInitialised();
const operations = ids.map((id) => ({delete: {_index: this.indexDefinition.indexName, _id: id}}));
await this.client.bulk({operations, refresh: false});
const response = await this.client.bulk({operations, refresh: false});
this.assertBulkSucceeded(response, 'delete');
}
async deleteByQuery(query: Record<string, unknown>): Promise<void> {
this.assertInitialised();
await this.client.deleteByQuery({
const response = await this.client.deleteByQuery({
index: this.indexDefinition.indexName,
query,
refresh: false,
});
this.assertDeleteByQuerySucceeded(response);
}
async deleteAllDocuments(): Promise<void> {
this.assertInitialised();
await this.client.deleteByQuery({
const response = await this.client.deleteByQuery({
index: this.indexDefinition.indexName,
query: {match_all: {}},
refresh: true,
});
this.assertDeleteByQuerySucceeded(response);
}
async search(query: string, filters: TFilters, options?: SearchOptions): Promise<SearchResult<TResult>> {
@@ -307,6 +315,29 @@ export class ElasticsearchIndexAdapter<
}
}
private assertBulkSucceeded(response: BulkResponse, operation: BulkOperationType): void {
if (!response.errors) return;
const context = `Elasticsearch bulk ${operation} failed for index ${this.indexDefinition.indexName}`;
for (const item of response.items ?? []) {
const failure = item[operation];
if (failure?.error) {
throw new Error(`${context}: ${summarizeOperationFailure(failure.status, failure.error)}`);
}
}
throw new Error(`${context}: response reported errors without ${operation} item error details`);
}
private assertDeleteByQuerySucceeded(response: DeleteByQueryResponse): void {
const context = `Elasticsearch delete by query failed for index ${this.indexDefinition.indexName}`;
if (response.timed_out) {
throw new Error(`${context}: timed out`);
}
const failure = response.failures?.[0];
if (failure) {
throw new Error(`${context}: ${summarizeOperationFailure(failure.status, failure.cause)}`);
}
}
private async executeDeepPaginationSearch(
searchParams: ElasticsearchBaseSearchRequest,
offset: number,
@@ -403,6 +434,12 @@ export class ElasticsearchIndexAdapter<
}
}
function summarizeOperationFailure(status: number, error: ErrorCause): string {
const errorType =
typeof error.type === 'string' ? JSON.stringify(error.type.slice(0, ERROR_TYPE_MAX_LENGTH)) : 'missing error type';
return `HTTP ${status}, ${errorType}`;
}
function isResourceAlreadyExistsError(error: unknown): boolean {
if (error == null || typeof error !== 'object') {
return false;
@@ -4,11 +4,17 @@ import type {Client} from '@elastic/elasticsearch';
import type {SortCombinations} from '@elastic/elasticsearch/lib/api/types';
import type {SearchOptions, SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
import type {MessageSearchFilters, SearchableMessage} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
import {compactFilters, esAndTerms, esExcludeAny, esTermFilter, esTermsFilter} from '../ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '../ElasticsearchIndexDefinitions';
import type {ElasticsearchDistributedLock} from './ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from './ElasticsearchIndexAdapter';
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {
compactFilters,
esAndTerms,
esExcludeAny,
esTermFilter,
esTermsFilter,
} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
const HAS_FIELD_MAP: Record<string, string> = {
image: 'hasImage',
@@ -3,11 +3,16 @@
import type {Client} from '@elastic/elasticsearch';
import type {SortCombinations} from '@elastic/elasticsearch/lib/api/types';
import type {ReportSearchFilters, SearchableReport} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
import {compactFilters, esExistsFilter, esNotExistsFilter, esTermFilter} from '../ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '../ElasticsearchIndexDefinitions';
import type {ElasticsearchDistributedLock} from './ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from './ElasticsearchIndexAdapter';
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {
compactFilters,
esExistsFilter,
esNotExistsFilter,
esTermFilter,
} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
function buildReportFilters(filters: ReportSearchFilters): Array<ElasticsearchFilter | undefined> {
const clauses: Array<ElasticsearchFilter | undefined> = [];
@@ -3,7 +3,9 @@
import type {Client} from '@elastic/elasticsearch';
import type {SortCombinations} from '@elastic/elasticsearch/lib/api/types';
import type {SearchableUser, UserSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {ElasticsearchFilter} from '../ElasticsearchFilterUtils';
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {
compactFilters,
esAndTerms,
@@ -11,10 +13,8 @@ import {
esNotExistsFilter,
esRangeFilter,
esTermFilter,
} from '../ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '../ElasticsearchIndexDefinitions';
import type {ElasticsearchDistributedLock} from './ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from './ElasticsearchIndexAdapter';
} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
function buildUserFilters(filters: UserSearchFilters): Array<ElasticsearchFilter | undefined> {
const clauses: Array<ElasticsearchFilter | undefined> = [];
+9 -2
View File
@@ -9,6 +9,13 @@ import type {IEmailService} from '@pkgs/email/src/IEmailService';
import {ms} from 'itty-time';
const logger = createLogger('email-service');
const DEFAULT_LOCALE = 'en-US';
function formatMinorUnitAmount(amountMinor: number, currency: string, locale: string | null): string {
const formatter = new Intl.NumberFormat(locale || DEFAULT_LOCALE, {style: 'currency', currency});
const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
return formatter.format(amountMinor / 10 ** fractionDigits);
}
export class EmailService implements IEmailService {
private readonly config: EmailConfig;
@@ -127,7 +134,7 @@ export class EmailService implements IEmailService {
async sendUnbanNotification(
email: string,
username: string,
reason: string,
reason: string | null,
locale: string | null = null,
): Promise<boolean> {
return this.sendTemplatedEmail(email, 'unban_notification', locale, {username, reason});
@@ -301,7 +308,7 @@ export class EmailService implements IEmailService {
locale: string | null = null,
): Promise<boolean> {
return this.sendTemplatedEmail(email, 'donation_confirmation', locale, {
amount: (amountCents / 100).toFixed(2),
amount: formatMinorUnitAmount(amountCents, currency, locale),
currency: currency.toUpperCase(),
interval,
manageUrl,
+6 -1
View File
@@ -43,7 +43,12 @@ export interface IEmailService {
deletionDate: Date,
locale?: string | null,
): Promise<boolean>;
sendUnbanNotification(email: string, username: string, reason: string, locale?: string | null): Promise<boolean>;
sendUnbanNotification(
email: string,
username: string,
reason: string | null,
locale?: string | null,
): Promise<boolean>;
sendScheduledDeletionNotification(
email: string,
username: string,
@@ -126,11 +126,11 @@ export class TestEmailService implements ITestEmailService {
async sendUnbanNotification(
email: string,
username: string,
reason: string,
reason: string | null,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(`Unban notification sent to ${email} for user ${username}, reason: ${reason}`);
return this.record(email, 'unban_notification', {reason});
this.logger.info(`Unban notification sent to ${email} for user ${username}, reason: ${reason ?? 'none'}`);
return this.record(email, 'unban_notification', {reason: reason ?? ''});
}
async sendScheduledDeletionNotification(
@@ -1,39 +1,39 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {EmailTemplate, EmailTemplateKey} from '@pkgs/email/src/email_i18n/EmailI18nTypes.generated';
import EMAIL_I18N_AR_MESSAGES from './locales/ar';
import EMAIL_I18N_BG_MESSAGES from './locales/bg';
import EMAIL_I18N_CS_MESSAGES from './locales/cs';
import EMAIL_I18N_DA_MESSAGES from './locales/da';
import EMAIL_I18N_DE_MESSAGES from './locales/de';
import EMAIL_I18N_EL_MESSAGES from './locales/el';
import EMAIL_I18N_EN_GB_MESSAGES from './locales/en-GB';
import EMAIL_I18N_ES_419_MESSAGES from './locales/es-419';
import EMAIL_I18N_ES_ES_MESSAGES from './locales/es-ES';
import EMAIL_I18N_FI_MESSAGES from './locales/fi';
import EMAIL_I18N_FR_MESSAGES from './locales/fr';
import EMAIL_I18N_HE_MESSAGES from './locales/he';
import EMAIL_I18N_HI_MESSAGES from './locales/hi';
import EMAIL_I18N_HR_MESSAGES from './locales/hr';
import EMAIL_I18N_HU_MESSAGES from './locales/hu';
import EMAIL_I18N_ID_MESSAGES from './locales/id';
import EMAIL_I18N_IT_MESSAGES from './locales/it';
import EMAIL_I18N_JA_MESSAGES from './locales/ja';
import EMAIL_I18N_KO_MESSAGES from './locales/ko';
import EMAIL_I18N_LT_MESSAGES from './locales/lt';
import EMAIL_I18N_NL_MESSAGES from './locales/nl';
import EMAIL_I18N_NO_MESSAGES from './locales/no';
import EMAIL_I18N_PL_MESSAGES from './locales/pl';
import EMAIL_I18N_PT_BR_MESSAGES from './locales/pt-BR';
import EMAIL_I18N_RO_MESSAGES from './locales/ro';
import EMAIL_I18N_RU_MESSAGES from './locales/ru';
import EMAIL_I18N_SV_SE_MESSAGES from './locales/sv-SE';
import EMAIL_I18N_TH_MESSAGES from './locales/th';
import EMAIL_I18N_TR_MESSAGES from './locales/tr';
import EMAIL_I18N_UK_MESSAGES from './locales/uk';
import EMAIL_I18N_VI_MESSAGES from './locales/vi';
import EMAIL_I18N_ZH_CN_MESSAGES from './locales/zh-CN';
import EMAIL_I18N_ZH_TW_MESSAGES from './locales/zh-TW';
import EMAIL_I18N_AR_MESSAGES from '@pkgs/email/src/email_i18n/locales/ar';
import EMAIL_I18N_BG_MESSAGES from '@pkgs/email/src/email_i18n/locales/bg';
import EMAIL_I18N_CS_MESSAGES from '@pkgs/email/src/email_i18n/locales/cs';
import EMAIL_I18N_DA_MESSAGES from '@pkgs/email/src/email_i18n/locales/da';
import EMAIL_I18N_DE_MESSAGES from '@pkgs/email/src/email_i18n/locales/de';
import EMAIL_I18N_EL_MESSAGES from '@pkgs/email/src/email_i18n/locales/el';
import EMAIL_I18N_EN_GB_MESSAGES from '@pkgs/email/src/email_i18n/locales/en-GB';
import EMAIL_I18N_ES_419_MESSAGES from '@pkgs/email/src/email_i18n/locales/es-419';
import EMAIL_I18N_ES_ES_MESSAGES from '@pkgs/email/src/email_i18n/locales/es-ES';
import EMAIL_I18N_FI_MESSAGES from '@pkgs/email/src/email_i18n/locales/fi';
import EMAIL_I18N_FR_MESSAGES from '@pkgs/email/src/email_i18n/locales/fr';
import EMAIL_I18N_HE_MESSAGES from '@pkgs/email/src/email_i18n/locales/he';
import EMAIL_I18N_HI_MESSAGES from '@pkgs/email/src/email_i18n/locales/hi';
import EMAIL_I18N_HR_MESSAGES from '@pkgs/email/src/email_i18n/locales/hr';
import EMAIL_I18N_HU_MESSAGES from '@pkgs/email/src/email_i18n/locales/hu';
import EMAIL_I18N_ID_MESSAGES from '@pkgs/email/src/email_i18n/locales/id';
import EMAIL_I18N_IT_MESSAGES from '@pkgs/email/src/email_i18n/locales/it';
import EMAIL_I18N_JA_MESSAGES from '@pkgs/email/src/email_i18n/locales/ja';
import EMAIL_I18N_KO_MESSAGES from '@pkgs/email/src/email_i18n/locales/ko';
import EMAIL_I18N_LT_MESSAGES from '@pkgs/email/src/email_i18n/locales/lt';
import EMAIL_I18N_NL_MESSAGES from '@pkgs/email/src/email_i18n/locales/nl';
import EMAIL_I18N_NO_MESSAGES from '@pkgs/email/src/email_i18n/locales/no';
import EMAIL_I18N_PL_MESSAGES from '@pkgs/email/src/email_i18n/locales/pl';
import EMAIL_I18N_PT_BR_MESSAGES from '@pkgs/email/src/email_i18n/locales/pt-BR';
import EMAIL_I18N_RO_MESSAGES from '@pkgs/email/src/email_i18n/locales/ro';
import EMAIL_I18N_RU_MESSAGES from '@pkgs/email/src/email_i18n/locales/ru';
import EMAIL_I18N_SV_SE_MESSAGES from '@pkgs/email/src/email_i18n/locales/sv-SE';
import EMAIL_I18N_TH_MESSAGES from '@pkgs/email/src/email_i18n/locales/th';
import EMAIL_I18N_TR_MESSAGES from '@pkgs/email/src/email_i18n/locales/tr';
import EMAIL_I18N_UK_MESSAGES from '@pkgs/email/src/email_i18n/locales/uk';
import EMAIL_I18N_VI_MESSAGES from '@pkgs/email/src/email_i18n/locales/vi';
import EMAIL_I18N_ZH_CN_MESSAGES from '@pkgs/email/src/email_i18n/locales/zh-CN';
import EMAIL_I18N_ZH_TW_MESSAGES from '@pkgs/email/src/email_i18n/locales/zh-TW';
export const EMAIL_I18N_LOCALE_MESSAGES = {
ar: EMAIL_I18N_AR_MESSAGES,
@@ -17,7 +17,7 @@ export const EMAIL_I18N_MESSAGES = {
},
donation_confirmation: {
subject: 'Thank you for your {product_name} donation',
body: 'Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {currency} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team',
body: 'Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team',
},
donation_magic_link: {
subject: 'Manage your {product_name} donations',
@@ -65,7 +65,7 @@ export const EMAIL_I18N_MESSAGES = {
},
password_change_verification: {
subject: 'Confirm your {product_name} password change',
body: "Hello {username},\n\nWe received a request to change the password on your {product_name} account.\n\nTo confirm this change, enter this code in the app:\n\n{code}\n\nThis code expires at {expiresAt}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately and enable two-factor authentication.\n\n– {product_name} Team",
body: "Hello {username},\n\nWe received a request to change the password on your {product_name} account.\n\nTo confirm this change, enter this code in the app:\n\n{code}\n\nThis code expires on {expiresAt, date, full} at {expiresAt, time, short}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately and enable two-factor authentication.\n\n– {product_name} Team",
},
password_reset: {
subject: 'Reset your {product_name} password',
@@ -111,6 +111,6 @@ export interface EmailTemplateVariables {
};
unban_notification: {
username: string;
reason: string;
reason: string | null;
};
}
@@ -1,95 +1,95 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
const EMAIL_I18N_AR_MESSAGES = defineEmailI18nLocaleMessages({
"account_disabled_suspicious": {
"subject": "تم تعطيل حسابك في {product_name} مؤقتًا",
"body": "مرحباً {username}،\n\nلقد قمنا بتعطيل حسابك في {product_name} مؤقتًا لأننا اكتشفنا نشاطًا مشبوهًا.\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nلاستعادة الوصول إلى حسابك، ستحتاج إلى إعادة تعيين كلمة المرور الخاصة بك:\n\n{forgotUrl}\n\nبعد إعادة تعيين كلمة المرور الخاصة بك، ستتمكن من تسجيل الدخول مرة أخرى.\n\nإذا كنت تعتقد أن هذا حدث عن طريق الخطأ، يرجى الاتصال بفريق الدعم لدينا.\n\n– فريق أمان {product_name}"
"body": "مرحبًا {username}،\n\nلقد قمنا بتعطيل حسابك في {product_name} مؤقتًا لأننا اكتشفنا نشاطًا مشبوهًا.\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nلاستعادة الوصول إلى حسابك، ستحتاج إلى إعادة تعيين كلمة المرور الخاصة بك:\n\n{forgotUrl}\n\nبعد إعادة تعيين كلمة المرور الخاصة بك، ستتمكن من تسجيل الدخول مرة أخرى.\n\nإذا كنت تعتقد أن هذا حدث عن طريق الخطأ، يرجى الاتصال بفريق الدعم لدينا.\n\n– فريق أمان {product_name}"
},
"account_scheduled_deletion": {
"subject": "سيتم حذف حسابك في {product_name} نهائيًا",
"body": "مرحباً {username}،\n\nتم جدولة حسابك في {product_name} للحذف الدائم بسبب انتهاكات لشروط الخدمة أو إرشادات المجتمع الخاصة بنا.\n\nالحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nهذا إجراء تنفيذي جاد. سيتم حذف بيانات حسابك نهائيًا في التاريخ المحدد.\n\nيرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n- إرشادات المجتمع: {guidelinesUrl}\n\nعملية الاستئناف:\nإذا كنت تعتقد أن قرار التنفيذ هذا كان غير صحيح أو غير مبرر، لديك 60 يومًا لتقديم استئناف. أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا.\n\nفي استئنافك:\n- اشرح بوضوح لماذا تعتقد أن قرار التنفيذ كان غير صحيح أو غير مبرر\n- قدم أي دليل أو سياق ذي صلة\n\nسيقوم عضو من فريق أمان {product_name} بمراجعة استئنافك وقد يوقف الحذف المعلق حتى يتم التوصل إلى قرار نهائي.\n\n– فريق أمان {product_name}"
"body": "مرحبًا {username}،\n\nتمت جدولة حسابك في {product_name} للحذف الدائم بسبب انتهاكات لشروط الخدمة أو إرشادات المجتمع الخاصة بنا.\n\nالحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nهذا إجراء إنفاذ جاد. سيتم حذف بيانات حسابك نهائيًا في التاريخ المحدد.\n\nيرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n- إرشادات المجتمع: {guidelinesUrl}\n\nعملية الاستئناف:\nإذا كنت تعتقد أن قرار الإنفاذ هذا كان غير صحيح أو غير مبرر، لديك 60 يومًا لتقديم استئناف. أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا.\n\nفي استئنافك:\n- اشرح بوضوح سبب اعتقادك أن قرار الإنفاذ كان غير صحيح أو غير مبرر\n- قدم أي دليل أو سياق ذي صلة\n\nسيقوم عضو من فريق أمان {product_name} بمراجعة استئنافك وقد يوقف الحذف المعلق مؤقتًا إلى حين التوصل إلى قرار نهائي.\n\n– فريق أمان {product_name}"
},
"account_temp_banned": {
"subject": "تم تعليق حسابك في {product_name} مؤقتًا",
"body": "مرحباً {username}،\n\nتم تعليق حسابك في {product_name} مؤقتًا لانتهاك شروط الخدمة أو إرشادات المجتمع الخاصة بنا.\n\nالمدة: {durationHours, plural,\n =1 {ساعة واحدة} zero {0 ساعات} two {ساعتان}\n few {ساعات} many {ساعة} other {# ساعة}\n}\nمعلق حتى: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nخلال هذه الفترة، لن تتمكن من الوصول إلى حسابك.\n\nيرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n- إرشادات المجتمع: {guidelinesUrl}\n\nإذا كنت تعتقد أن قرار التنفيذ هذا كان غير صحيح أو غير مبرر، يمكنك تقديم استئناف. أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا واشرح بوضوح لماذا تعتقد أن القرار كان غير صحيح. سنراجع استئنافك ونرد بقرارنا.\n\n– فريق أمان {product_name}"
"body": "مرحبًا {username}،\n\nتم تعليق حسابك في {product_name} مؤقتًا لانتهاك شروط الخدمة أو إرشادات المجتمع الخاصة بنا.\n\nالمدة: {durationHours, plural,\n =1 {ساعة واحدة}\n zero {# ساعة}\n two {ساعتان}\n few {# ساعات}\n many {# ساعة}\n other {# ساعة}\n}\nمعلق حتى: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nخلال هذه الفترة، لن تتمكن من الوصول إلى حسابك.\n\nيرجى مراجعة:\n- شروط الخدمة: {termsUrl}\n- إرشادات المجتمع: {guidelinesUrl}\n\nإذا كنت تعتقد أن قرار الإنفاذ هذا كان غير صحيح أو غير مبرر، يمكنك تقديم استئناف. أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا واشرح بوضوح سبب اعتقادك أن القرار كان غير صحيح. سنراجع استئنافك ونرد بقرارنا.\n\n– فريق أمان {product_name}"
},
"donation_confirmation": {
"subject": "شكرًا لك على تبرعك لـ {product_name}",
"body": "مرحباً،\n\nشكرًا لك على تبرعك لـ {product_name}! لقد تم {interval, select,\n month {إعداد تبرعك المتكرر}\n year {إعداد تبرعك المتكرر}\n other {معالجة تبرعك لمرة واحدة}\n} بنجاح.\n\nتفاصيل التبرع:\nالمبلغ: {amount} {currency} {interval, select,\n month {شهريًا}\n year {سنويًا}\n other {}\n}\n\nسترسل لك Stripe إيصالًا منفصلاً عبر البريد الإلكتروني مع فاتورتك بصيغة PDF قريبًا. يتضمن هذا جميع تفاصيل الدفع ويمكن استخدامه لأغراض ضريبية.\n\nيمكنك عرض سجل تبرعاتك، وتنزيل الفواتير، {interval, select,\n month {وإدارة أو إلغاء اشتراكك}\n year {وإدارة أو إلغاء اشتراكك}\n other {وإدارة التبرعات المستقبلية}\n} في أي وقت باستخدام هذا الرابط:\n\n{manageUrl}\n\nدعمك يساعد في استمرار {product_name}. شكرًا لك!\n\n– فريق {product_name}{interval, select,\n month {}\n year {}\n other {}\n}"
"subject": "شكرًا لك على تبرعك لـ{product_name}",
"body": "مرحبًا،\n\nشكرًا لك على تبرعك لـ{product_name}! لقد تم {interval, select,\n month {إعداد تبرعك المتكرر}\n year {إعداد تبرعك المتكرر}\n other {معالجة تبرعك لمرة واحدة}\n} بنجاح.\n\nتفاصيل التبرع:\nالمبلغ: {amount} {interval, select,\n month {شهريًا}\n year {سنويًا}\n other {}\n}\n\nسترسل لك Stripe إيصالًا منفصلًا عبر البريد الإلكتروني مع فاتورتك بصيغة PDF قريبًا. يتضمن هذا جميع تفاصيل الدفع ويمكن استخدامه لأغراض ضريبية.\n\nيمكنك عرض سجل تبرعاتك، وتنزيل الفواتير، {interval, select,\n month {وإدارة أو إلغاء اشتراكك}\n year {وإدارة أو إلغاء اشتراكك}\n other {وإدارة التبرعات المستقبلية}\n} في أي وقت باستخدام هذا الرابط:\n\n{manageUrl}\n\nدعمك يساعد على استمرار {product_name}. شكرًا لك!\n\n– فريق {product_name}"
},
"donation_magic_link": {
"subject": "إدارة تبرعات {product_name}",
"body": "مرحباً،\n\nانقر على الرابط أدناه للوصول إلى بوابة المتبرعين الخاصة بك:\n\n{manageUrl}\n\nفي البوابة، يمكنك إدارة الاشتراكات، وتنزيل الفواتير، وعرض سجل تبرعاتك.\n\nينتهي صلاحية هذا الرابط في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا الرابط، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\n– فريق {product_name}"
"subject": "إدارة تبرعاتك لـ{product_name}",
"body": "مرحبًا،\n\nانقر على الرابط أدناه للوصول إلى بوابة المتبرعين الخاصة بك:\n\n{manageUrl}\n\nفي البوابة، يمكنك إدارة الاشتراكات، وتنزيل الفواتير، وعرض سجل تبرعاتك.\n\nتنتهي صلاحية هذا الرابط في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا الرابط، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\n– فريق {product_name}"
},
"dsa_report_verification": {
"subject": "تحقق من بريدك الإلكتروني لتقديم تقرير DSA",
"body": "مرحباً،\n\nاستخدم رمز التحقق أدناه لتقديم تقرير قانون الخدمات الرقمية الخاص بك على {product_name}:\n\n{code}\n\nينتهي هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني.\n\n– فريق أمان {product_name}"
"subject": "تحقق من بريدك الإلكتروني لتقديم بلاغ DSA",
"body": "مرحبًا،\n\nاستخدم رمز التحقق أدناه لتقديم بلاغك بموجب قانون الخدمات الرقمية على {product_name}:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني.\n\n– فريق أمان {product_name}"
},
"email_change_new": {
"subject": "تحقق من بريدك الإلكتروني الجديد في {product_name}",
"body": "مرحباً {username}،\n\nأدخل هذا الرمز في التطبيق للتحقق من بريدك الإلكتروني الجديد في {product_name}:\n\n{code}\n\nينتهي هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني.\n\n– فريق {product_name}"
"body": "مرحبًا {username}،\n\nأدخل هذا الرمز في التطبيق للتحقق من بريدك الإلكتروني الجديد في {product_name}:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني.\n\n– فريق {product_name}"
},
"email_change_original": {
"subject": "تأكيد تغيير بريدك الإلكتروني في {product_name}",
"body": "مرحباً {username}،\n\nلقد تلقينا طلبًا لتغيير عنوان البريد الإلكتروني لحسابك في {product_name}.\n\nلتأكيد هذا التغيير، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nينتهي هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، يرجى تأمين حسابك على الفور.\n\n– فريق {product_name}"
"body": "مرحبًا {username}،\n\nلقد تلقينا طلبًا لتغيير عنوان البريد الإلكتروني لحسابك في {product_name}.\n\nلتأكيد هذا التغيير، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، يرجى تأمين حسابك على الفور.\n\n– فريق {product_name}"
},
"email_change_revert": {
"subject": "تم تغيير بريدك الإلكتروني في {product_name}",
"body": "مرحباً {username}،\n\nتم تغيير عنوان البريد الإلكتروني لحسابك في {product_name} إلى {newEmail}.\n\nإذا قمت بهذا التغيير، فلا داعي لاتخاذ أي إجراء. إذا لم تقم بذلك، يمكنك التراجع عن التغيير وتأمين حسابك باستخدام هذا الرابط:\n\n{revertUrl}\n\nسيؤدي هذا إلى استعادة بريدك الإلكتروني السابق، وتسجيل خروجك من جميع الأجهزة، وإزالة أرقام الهواتف المرتبطة، وتعطيل المصادقة متعددة العوامل، وسيطلب منك تعيين كلمة مرور جديدة.\n\n– فريق أمان {product_name}"
"body": "مرحبًا {username}،\n\nتم تغيير عنوان البريد الإلكتروني لحسابك في {product_name} إلى {newEmail}.\n\nإذا قمت بهذا التغيير، فلا داعي لاتخاذ أي إجراء. إذا لم تقم بذلك، يمكنك التراجع عن التغيير وتأمين حسابك باستخدام هذا الرابط:\n\n{revertUrl}\n\nسيؤدي هذا إلى استعادة بريدك الإلكتروني السابق، وتسجيل خروجك من جميع الأجهزة، وإزالة أرقام الهواتف المرتبطة، وتعطيل المصادقة متعددة العوامل، وسيُطلب منك تعيين كلمة مرور جديدة.\n\n– فريق أمان {product_name}"
},
"email_verification": {
"subject": "تحقق من بريدك الإلكتروني في {product_name}",
"body": "مرحباً {username}،\n\nيرجى التحقق من عنوان البريد الإلكتروني لحسابك في {product_name} بالنقر على الرابط أدناه:\n\n{verifyUrl}\n\nإذا لم تقم بإنشاء حساب {product_name}، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\nهذا الرابط صالح لمدة 24 ساعة.\n\n– فريق {product_name}"
"body": "مرحبًا {username}،\n\nيرجى التحقق من عنوان البريد الإلكتروني لحسابك في {product_name} بالنقر على الرابط أدناه:\n\n{verifyUrl}\n\nإذا لم تقم بإنشاء حساب {product_name}، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\nهذا الرابط صالح لمدة 24 ساعة.\n\n– فريق {product_name}"
},
"gift_chargeback_notification": {
"subject": "تمت إزالة المزايا من هديتك",
"body": "مرحباً {username}،\n\nرمز الهدية الذي استرددته تم دفعه في الأصل من قبل شخص آخر. وقد تم عكس هذا الدفع منذ ذلك الحين (استرداد المبلغ).\n\nبسبب هذا، قمنا بإزالة المزايا التي أضيفت إلى حسابك عندما استرددت الهدية.\n\nإذا كنت تعتقد أن هذا خطأ، يرجى الاتصال بفريق الدعم لدينا وتضمين أي تفاصيل لديك حول رمز الهدية ومتى استرددته.\n\n– فريق {product_name}"
"subject": "تمت إزالة مزايا الهدية التي استرددتها",
"body": "مرحبًا {username}،\n\nدفع شخص آخر في الأصل ثمن رمز الهدية الذي استرددته، وقد تم التراجع عن تلك الدفعة لاحقًا (رد مبلغ مدفوع).\n\nولهذا السبب أزلنا المزايا التي أُضيفت إلى حسابك عند استرداد الهدية.\n\nإذا كنت تعتقد أن هذا خطأ، يرجى الاتصال بفريق الدعم لدينا مع ذكر أي تفاصيل لديك عن رمز الهدية ووقت استردادك له.\n\n– فريق {product_name}"
},
"harvest_completed": {
"subject": "تصدير بياناتك من {product_name} جاهز للتنزيل",
"body": "مرحباً {username}،\n\nتصدير بياناتك جاهز.\n\nرابط التنزيل:\n{downloadUrl}\n\nالرسائل المتضمنة: {totalMessages, number}\nحجم الملف: {fileSizeMB, number} ميجابايت\n\nينتهي هذا الرابط في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا التصدير، يرجى تغيير كلمة المرور الخاصة بك على الفور والاتصال بفريق الدعم لدينا.\n\n– فريق {product_name}"
"body": "مرحبًا {username}،\n\nتصدير بياناتك جاهز.\n\nرابط التنزيل:\n{downloadUrl}\n\nالرسائل المتضمنة: {totalMessages, number}\nحجم الملف: {fileSizeMB, number} ميجابايت\n\nتنتهي صلاحية هذا الرابط في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا التصدير، يرجى تغيير كلمة المرور الخاصة بك على الفور والاتصال بفريق الدعم لدينا.\n\n– فريق {product_name}"
},
"inactivity_warning": {
"subject": "سيتم حذف حسابك في {product_name} بسبب عدم النشاط",
"body": "مرحباً {username}،\n\nلم نرَ أي نشاط على حسابك في {product_name} منذ {lastActiveDate, date, full}.\n\nإذا لم تسجل الدخول بحلول {deletionDate, date, full} الساعة {deletionDate, time, short}، فسيتم حذف حسابك نهائيًا بسبب عدم النشاط.\n\nسجل الدخول هنا:\n{loginUrl}\n\nإذا كنت قد استخدمت {product_name} مؤخرًا، يرجى الاتصال بفريق الدعم لدينا على الفور.\n\n– فريق {product_name}"
"body": "مرحبًا {username}،\n\nلم نرَ أي نشاط على حسابك في {product_name} منذ {lastActiveDate, date, full}.\n\nإذا لم تسجل الدخول بحلول {deletionDate, date, full} الساعة {deletionDate, time, short}، فسيتم حذف حسابك نهائيًا بسبب عدم النشاط.\n\nسجل الدخول هنا:\n{loginUrl}\n\nإذا كنت قد استخدمت {product_name} مؤخرًا، يرجى الاتصال بفريق الدعم لدينا على الفور.\n\n– فريق {product_name}"
},
"ip_authorization": {
"subject": "السماح بتسجيل الدخول من عنوان IP جديد",
"body": "مرحباً {username}،\n\nلقد اكتشفنا محاولة تسجيل دخول إلى حسابك في {product_name} من عنوان IP جديد:\n\nعنوان IP: {ipAddress}\nالموقع: {location}\n\nإذا كنت أنت من قام بذلك، يرجى تفويض عنوان IP هذا بالنقر على الرابط أدناه:\n\n{authUrl}\n\nإذا لم تحاول تسجيل الدخول، يرجى تغيير كلمة المرور الخاصة بك على الفور.\n\nهذا الرابط صالح لـ 30 دقيقة.\n\n– فريق {product_name}"
"body": "مرحبًا {username}،\n\nلقد اكتشفنا محاولة تسجيل دخول إلى حسابك في {product_name} من عنوان IP جديد:\n\nعنوان IP: {ipAddress}\nالموقع: {location}\n\nإذا كنت أنت من قام بذلك، يرجى السماح بعنوان IP هذا بالنقر على الرابط أدناه:\n\n{authUrl}\n\nإذا لم تحاول تسجيل الدخول، يرجى تغيير كلمة المرور الخاصة بك على الفور.\n\nهذا الرابط صالح لمدة 30 دقيقة.\n\n– فريق {product_name}"
},
"mfa_backup_codes_view": {
"subject": "تأكيد الوصول إلى الرموز الاحتياطية في {product_name}",
"body": "مرحباً {username}،\n\nلقد تلقينا طلبًا لعرض الرموز الاحتياطية لحسابك في {product_name}.\n\nلتأكيد هذا الطلب، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nينتهي هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قد وصل إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور.\n\n– فريق {product_name}"
"body": "مرحبًا {username}،\n\nلقد تلقينا طلبًا لعرض الرموز الاحتياطية لحسابك في {product_name}.\n\nلتأكيد هذا الطلب، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قادرًا على الوصول إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور.\n\n– فريق {product_name}"
},
"password_change_verification": {
"subject": "تأكيد تغيير كلمة المرور في {product_name}",
"body": "مرحباً {username}،\n\nلقد تلقينا طلبًا لتغيير كلمة المرور لحسابك في {product_name}.\n\nلتأكيد هذا التغيير، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nينتهي هذا الرمز في {expiresAt}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قد وصل إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور وقم بتمكين المصادقة الثنائية.\n\n– فريق {product_name}"
"body": "مرحبًا {username}،\n\nلقد تلقينا طلبًا لتغيير كلمة المرور لحسابك في {product_name}.\n\nلتأكيد هذا التغيير، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nتنتهي صلاحية هذا الرمز في {expiresAt}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قادرًا على الوصول إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور وقم بتمكين المصادقة الثنائية.\n\n– فريق {product_name}"
},
"password_reset": {
"subject": "إعادة تعيين كلمة المرور في {product_name}",
"body": "مرحباً {username}،\n\nلقد طلبت إعادة تعيين كلمة المرور الخاصة بك في {product_name}. استخدم الرابط أدناه لتعيين كلمة مرور جديدة:\n\n{resetUrl}\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\nهذا الرابط صالح لساعة واحدة.\n\n– فريق {product_name}"
"body": "مرحبًا {username}،\n\nلقد طلبت إعادة تعيين كلمة المرور الخاصة بك في {product_name}. استخدم الرابط أدناه لتعيين كلمة مرور جديدة:\n\n{resetUrl}\n\nإذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني بأمان.\n\nهذا الرابط صالح لمدة ساعة واحدة.\n\n– فريق {product_name}"
},
"registration_approved": {
"subject": "تمت الموافقة على تسجيلك في {product_name}",
"body": "مرحباً {username}،\n\nأخبار سارة: تمت الموافقة على تسجيلك في {product_name}.\n\nيمكنك الآن تسجيل الدخول إلى تطبيق {product_name} من هنا:\n{channelsUrl}\n\nمرحباً بك في مجتمع {product_name}.\n\n– فريق {product_name}"
"body": "مرحبًا {username}،\n\nأخبار سارة: تمت الموافقة على تسجيلك في {product_name}.\n\nيمكنك الآن تسجيل الدخول إلى تطبيق {product_name} من هنا:\n{channelsUrl}\n\nمرحبًا بك في مجتمع {product_name}.\n\n– فريق {product_name}"
},
"report_resolved": {
"subject": "تمت مراجعة تقريرك في {product_name}",
"body": "مرحباً {username}،\n\nتمت مراجعة تقريرك (المعرف: {reportId}) من قبل فريق الأمان لدينا.{hasComment, select, yes {\n\nرد فريق الأمان:\n{publicComment}} other {}}\n\nشكرًا لمساعدتك في الحفاظ على {product_name} آمنًا للجميع. نحن نأخذ جميع التقارير على محمل الجد ونقدر مساهمتك في المجتمع.\n\nإذا كان لديك أي أسئلة أو مخاوف بشأن هذه النتيجة، يرجى الاتصال بـ {safety_email}.\n\n– فريق أمان {product_name}"
"subject": "تمت مراجعة بلاغك في {product_name}",
"body": "مرحبًا {username}،\n\nتمت مراجعة بلاغك (المعرف: {reportId}) من قبل فريق الأمان لدينا.{hasComment, select, yes {\n\nرد فريق الأمان:\n{publicComment}} other {}}\n\nشكرًا لمساعدتك في الحفاظ على {product_name} آمنًا للجميع. نحن نأخذ جميع البلاغات على محمل الجد ونقدر مساهمتك في المجتمع.\n\nإذا كان لديك أي أسئلة أو مخاوف بشأن هذه النتيجة، يرجى الاتصال بـ{safety_email}.\n\n– فريق أمان {product_name}"
},
"scheduled_deletion_notification": {
"subject": "سيتم حذف حسابك في {product_name} نهائيًا",
"body": "مرحباً {username}،\n\nتم جدولة حسابك في {product_name} للحذف الدائم.\n\nالحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nهذا إجراء تنفيذي جاد. سيتم حذف بيانات حسابك نهائيًا في التاريخ المحدد.\n\nإذا كنت تعتقد أن قرار التنفيذ هذا كان غير صحيح، يمكنك تقديم استئناف. أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا.\n\n– فريق أمان {product_name}"
"body": "مرحبًا {username}،\n\nتمت جدولة حسابك في {product_name} للحذف الدائم.\n\nالحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nهذا إجراء إنفاذ جاد. سيتم حذف بيانات حسابك نهائيًا في التاريخ المحدد.\n\nإذا كنت تعتقد أن قرار الإنفاذ هذا كان غير صحيح، يمكنك تقديم استئناف. أرسل بريدًا إلكترونيًا إلى {appeals_email} من عنوان البريد الإلكتروني هذا.\n\n– فريق أمان {product_name}"
},
"self_deletion_scheduled": {
"subject": "تم جدولة حذف حسابك في {product_name}",
"body": "مرحباً {username}،\n\nلقد طلبت حذف حسابك في {product_name}. تم جدولة حسابك للحذف الدائم في:\n\n{deletionDate, date, full} الساعة {deletionDate, time, short}\n\nإذا لم تطلب هذا، سجل الدخول إلى حسابك لإلغاء الحذف. نوصي أيضًا بتغيير كلمة المرور الخاصة بك لتأمين حسابك.\n\n– فريق {product_name}"
"subject": "تمت جدولة حذف حسابك في {product_name}",
"body": "مرحبًا {username}،\n\nلقد طلبت حذف حسابك في {product_name}. تمت جدولة حسابك للحذف الدائم في:\n\n{deletionDate, date, full} الساعة {deletionDate, time, short}\n\nإذا لم تطلب هذا، سجل الدخول إلى حسابك لإلغاء الحذف. نوصي أيضًا بتغيير كلمة المرور الخاصة بك لتأمين حسابك.\n\n– فريق {product_name}"
},
"unban_notification": {
"subject": "تم رفع تعليق حسابك في {product_name}",
"body": "مرحباً {username}،\n\nأخبار سارة: تم رفع تعليق حسابك في {product_name}.\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nيمكنك الآن تسجيل الدخول مرة أخرى ومواصلة استخدام {product_name} كالمعتاد.\n\n– فريق أمان {product_name}"
"body": "مرحبًا {username}،\n\nأخبار سارة: تم رفع تعليق حسابك في {product_name}.\n\n{reason, select,\n null {}\n other {السبب: {reason}}\n}\n\nيمكنك الآن تسجيل الدخول مرة أخرى ومواصلة استخدام {product_name} كالمعتاد.\n\n– فريق أمان {product_name}"
}
});
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
"account_disabled_suspicious": {
@@ -9,23 +9,23 @@ const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
},
"account_scheduled_deletion": {
"subject": "Акаунтът ти във {product_name} ще бъде изтрит за постоянно",
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е насрочен за постоянно изтриване поради нарушения на нашите Условия за ползване или Насоки на общността.\n\nНасрочено изтриване: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Причина: {reason}}\n}\n\nТова е сериозна мярка. Данните на акаунта ти ще бъдат изтрити за постоянно на насрочената дата.\n\nПрегледай:\n- Условия за ползване: {termsUrl}\n- Насоки на общността: {guidelinesUrl}\n\nПроцес на обжалване:\nАко смяташ, че това решение е неправилно или необосновано, имаш 60 дни да подадеш обжалване. Изпрати имейл на {appeals_email} от този имейл адрес.\n\nВ обжалването си:\n- Обясни ясно защо смяташ, че решението е неправилно или необосновано\n- Предостави всякакви релевантни доказателства или контекст\n\nЧлен на екипа за безопасност на {product_name} ще прегледа обжалването ти и може да спре предстоящото изтриване, докато не бъде взето окончателно решение.\n\n– Екип за безопасност на {product_name}"
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е насрочен за постоянно изтриване поради нарушения на нашите Общи условия или Насоки на общността.\n\nНасрочено изтриване: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Причина: {reason}}\n}\n\nТова е сериозна мярка. Данните на акаунта ти ще бъдат изтрити за постоянно на насрочената дата.\n\nПрегледай:\n- Общи условия: {termsUrl}\n- Насоки на общността: {guidelinesUrl}\n\nПроцес на обжалване:\nАко смяташ, че това решение е неправилно или необосновано, имаш 60 дни да подадеш обжалване. Изпрати имейл на {appeals_email} от този имейл адрес.\n\nВ обжалването си:\n- Обясни ясно защо смяташ, че решението е неправилно или необосновано\n- Приложи всички релевантни доказателства или контекст\n\nЧлен на екипа за безопасност на {product_name} ще прегледа обжалването ти и може да спре предстоящото изтриване, докато не бъде взето окончателно решение.\n\n– Екип за безопасност на {product_name}"
},
"account_temp_banned": {
"subject": "Акаунтът ти във {product_name} е временно спрян",
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е временно спрян поради нарушаване на нашите Условия за ползване или Насоки на общността.\n\nПродължителност: {durationHours, plural,\n =1 {1 час}\n other {# часа}\n}\nСпрян до: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Причина: {reason}}\n}\n\nПрез това време няма да имаш достъп до акаунта си.\n\nПрегледай:\n- Условия за ползване: {termsUrl}\n- Насоки на общността: {guidelinesUrl}\n\nАко смяташ, че това решение е неправилно или необосновано, можеш да подадеш обжалване. Изпрати имейл на {appeals_email} от този имейл адрес и ясно обясни защо смяташ, че решението е неправилно. Ние ще прегледаме обжалването ти и ще отговорим с нашето решение.\n\n– Екип за безопасност на {product_name}"
"body": "Здравей, {username},\n\nАкаунтът ти във {product_name} е временно спрян поради нарушаване на нашите Общи условия или Насоки на общността.\n\nПродължителност: {durationHours, plural,\n =1 {1 час}\n other {# часа}\n}\nСпрян до: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Причина: {reason}}\n}\n\nПрез това време няма да имаш достъп до акаунта си.\n\nПрегледай:\n- Общи условия: {termsUrl}\n- Насоки на общността: {guidelinesUrl}\n\nАко смяташ, че това решение е неправилно или необосновано, можеш да подадеш обжалване. Изпрати имейл на {appeals_email} от този имейл адрес и ясно обясни защо смяташ, че решението е неправилно. Ще прегледаме обжалването ти и ще ти отговорим с решението си.\n\n– Екип за безопасност на {product_name}"
},
"donation_confirmation": {
"subject": "Благодарим ти за дарението за {product_name}",
"body": "Здравей,\n\nБлагодарим ти за дарението за {product_name}! Твоето {interval, select,\n month {периодично дарение}\n year {периодично дарение}\n other {еднократно дарение}\n} беше {interval, select,\n month {настроено}\n year {настроено}\n other {обработено}\n} успешно.\n\nПодробности за дарението:\nСума: {amount} {currency} {interval, select,\n month {на месец}\n year {на година}\n other {}\n}\n\nStripe ще ти изпрати отделна разписка с PDF фактурата ти скоро. Тя включва всички данни за плащане и може да се използва за данъчни цели.\n\nМожеш да прегледаш историята на даренията си, да изтегляш фактури, {interval, select,\n month {и да управляваш или анулираш абонамента си}\n year {и да управляваш или анулираш абонамента си}\n other {и да управляваш бъдещи дарения}\n} по всяко време, като използваш този линк:\n\n{manageUrl}\n\nТвоята подкрепа помага на {product_name} да продължи да работи. Благодарим ти!\n\n– Екип на {product_name}"
"subject": "Благодарим ти за дарението към {product_name}",
"body": "Здравей,\n\nБлагодарим ти за дарението към {product_name}! Твоето {interval, select,\n month {периодично дарение}\n year {периодично дарение}\n other {еднократно дарение}\n} беше {interval, select,\n month {настроено}\n year {настроено}\n other {обработено}\n} успешно.\n\nПодробности за дарението:\nСума: {amount} {interval, select,\n month {на месец}\n year {на година}\n other {}\n}\n\nСкоро Stripe ще ти изпрати по имейл отделна разписка с PDF на фактурата ти. Тя включва всички данни за плащане и може да се използва за данъчни цели.\n\nМожеш да преглеждаш историята на даренията си, да изтегляш фактури {interval, select,\n month {и да управляваш или анулираш абонамента си}\n year {и да управляваш или анулираш абонамента си}\n other {и да управляваш бъдещи дарения}\n} по всяко време, като използваш този линк:\n\n{manageUrl}\n\nТвоята подкрепа помага на {product_name} да продължи да работи. Благодарим ти!\n\n– Екип на {product_name}"
},
"donation_magic_link": {
"subject": "Управлявай даренията си за {product_name}",
"body": "Здравей,\n\nКликни върху линка по-долу, за да получиш достъп до портала за дарители:\n\n{manageUrl}\n\nВ портала можеш да управляваш планове, да изтегляш фактури и да преглеждаш историята на даренията си.\n\nТози линк изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си поискал този линк, можеш спокойно да игнорираш този имейл.\n\n– Екип на {product_name}"
"body": "Здравей,\n\nКликни върху линка по-долу, за да получиш достъп до портала за дарители:\n\n{manageUrl}\n\nВ портала можеш да управляваш абонаменти, да изтегляш фактури и да преглеждаш историята на даренията си.\n\nТози линк изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си поискал този линк, можеш спокойно да игнорираш този имейл.\n\n– Екип на {product_name}"
},
"dsa_report_verification": {
"subject": "Потвърди имейла си за DSA доклад",
"body": "Здравей,\n\nИзползвай кода за потвърждение по-долу, за да подадеш своя доклад по Закона за цифровите услуги във {product_name}:\n\n{code}\n\nТози код изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си го поискал, можеш да игнорираш този имейл.\n\n– Екип за безопасност на {product_name}"
"subject": "Потвърди имейла си за сигнал по DSA",
"body": "Здравей,\n\nИзползвай кода за потвърждение по-долу, за да подадеш сигнала си по Закона за цифровите услуги във {product_name}:\n\n{code}\n\nТози код изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си го поискал, можеш да игнорираш този имейл.\n\n– Екип за безопасност на {product_name}"
},
"email_change_new": {
"subject": "Потвърди новия си имейл във {product_name}",
@@ -37,19 +37,19 @@ const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
},
"email_change_revert": {
"subject": "Имейлът ти във {product_name} беше променен",
"body": "Здравей, {username},\n\nИмейл адресът на акаунта ти във {product_name} беше променен на {newEmail}.\n\nАко ти си направил тази промяна, не е необходимо да предприемаш никакви действия. Ако не си, можеш да отмениш промяната и да защитиш акаунта си, като използваш този линк:\n\n{revertUrl}\n\nТова ще възстанови предишния ти имейл, ще те излезе от всички устройства, ще премахне свързаните телефонни номера, ще деактивира MFA и ще изисква да зададеш нова парола.\n\n– Екип за безопасност на {product_name}"
"body": "Здравей, {username},\n\nИмейл адресът на акаунта ти във {product_name} беше променен на {newEmail}.\n\nАко ти си направил тази промяна, не е необходимо да предприемаш никакви действия. Ако не си, можеш да отмениш промяната и да защитиш акаунта си, като използваш този линк:\n\n{revertUrl}\n\nТова ще възстанови предишния ти имейл, ще прекрати всички активни сесии, ще премахне свързаните телефонни номера, ще деактивира MFA и ще изиска да зададеш нова парола.\n\n– Екип за безопасност на {product_name}"
},
"email_verification": {
"subject": "Потвърди имейл адреса си във {product_name}",
"body": "Здравей, {username},\n\nПотвърди имейл адреса за акаунта си във {product_name}, като кликнеш върху линка по-долу:\n\n{verifyUrl}\n\nАко не си създал акаунт във {product_name}, можеш спокойно да игнорираш този имейл.\n\nТози линк е валиден 24 часа.\n\n– Екип на {product_name}"
},
"gift_chargeback_notification": {
"subject": "Предимствата от активирания ти подарък са премахнати",
"body": "Здравей, {username},\n\nКод за подарък, който си активирал, първоначално е бил платен от някой друг. Това плащане оттогава е отменено (chargeback).\n\nПоради това премахнахме предимствата, които бяха добавени към акаунта ти, когато активира подаръка.\n\nАко смяташ, че това е грешка, свържи се с нашия екип за поддръжка и включи всички подробности, които имаш относно кода за подарък и кога си го активирал.\n\n– Екип на {product_name}"
"subject": "Предимствата от осребрения ти подарък са премахнати",
"body": "Здравей, {username},\n\nКод за подарък, който си осребрил, първоначално е бил платен от някой друг. Оттогава плащането е върнато чрез оспорване на трансакция.\n\nПоради това премахнахме предимствата, които бяха добавени към акаунта ти, когато осребри подаръка.\n\nАко смяташ, че това е грешка, свържи се с нашия екип за поддръжка и посочи всички подробности, които имаш за кода за подарък и кога си го осребрил.\n\n– Екип на {product_name}"
},
"harvest_completed": {
"subject": "Експортът на данните ти от {product_name} е готов за изтегляне",
"body": "Здравей, {username},\n\nЕкспортът на данните ти е готов.\n\nЛинк за изтегляне:\n{downloadUrl}\n\nВключени съобщения: {totalMessages, number}\nРазмер на файла: {fileSizeMB, number} MB\n\nТози линк изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си го поискал, смени паролата си незабавно и се свържи с нашия екип за поддръжка.\n\n– Екип на {product_name}"
"body": "Здравей, {username},\n\nЕкспортът на данните ти е готов.\n\nЛинк за изтегляне:\n{downloadUrl}\n\nВключени съобщения: {totalMessages, number}\nРазмер на файла: {fileSizeMB, number} MB\n\nТози линк изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си поискал този експорт, смени паролата си незабавно и се свържи с нашия екип за поддръжка.\n\n– Екип на {product_name}"
},
"inactivity_warning": {
"subject": "Акаунтът ти във {product_name} ще бъде изтрит поради неактивност",
@@ -76,8 +76,8 @@ const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
"body": "Здравей, {username},\n\nДобра новина: регистрацията ти във {product_name} е одобрена.\n\nВече можеш да влезеш в приложението {product_name} тук:\n{channelsUrl}\n\nДобре дошъл в общността на {product_name}.\n\n– Екип на {product_name}"
},
"report_resolved": {
"subject": "Докладът ти във {product_name} е прегледан",
"body": "Здравей, {username},\n\nДокладът ти (ID: {reportId}) е прегледан от нашия екип за безопасност.{hasComment, select, yes {\n\nОтговор от екипа за безопасност:\n{publicComment}} other {}}\n\nБлагодарим ти, че помагаш {product_name} да остане безопасно място за всички. Ние приемаме всички доклади сериозно и ценим твоя принос към общността.\n\nАко имаш въпроси или притеснения относно този резултат, свържи се с {safety_email}.\n\n– Екип за безопасност на {product_name}"
"subject": "Сигналът ти във {product_name} е прегледан",
"body": "Здравей, {username},\n\nСигналът ти (ID: {reportId}) е прегледан от нашия екип за безопасност.{hasComment, select, yes {\n\nОтговор от екипа за безопасност:\n{publicComment}} other {}}\n\nБлагодарим ти, че помагаш на {product_name} да остане безопасно място за всички. Приемаме всички сигнали сериозно и ценим приноса ти към общността.\n\nАко имаш въпроси или притеснения относно този резултат, свържи се с {safety_email}.\n\n– Екип за безопасност на {product_name}"
},
"scheduled_deletion_notification": {
"subject": "Акаунтът ти във {product_name} ще бъде изтрит за постоянно",
@@ -1,95 +1,95 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
const EMAIL_I18N_CS_MESSAGES = defineEmailI18nLocaleMessages({
"account_disabled_suspicious": {
"subject": "Tvůj účet {product_name} byl dočasně deaktivován",
"body": "Ahoj {username},\n\nDočasně jsme deaktivovali tvůj účet {product_name}, protože jsme zaznamenali podezřelou aktivitu.\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nPro opětovný přístup k účtu si budeš muset resetovat heslo:\n\n{forgotUrl}\n\nPo resetování hesla se budeš moci znovu přihlásit.\n\nPokud se domníváš, že se jedná o chybu, kontaktuj prosím náš tým podpory.\n\n– Bezpečnostní tým {product_name}"
"subject": "Váš účet {product_name} byl dočasně deaktivován",
"body": "Dobrý den, {username},\n\nDočasně jsme deaktivovali váš účet {product_name}, protože jsme zaznamenali podezřelou aktivitu.\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nAbyste znovu získali přístup k účtu, musíte si nastavit nové heslo:\n\n{forgotUrl}\n\nPo změně hesla se budete moci znovu přihlásit.\n\nPokud se domníváte, že jde o chybu, kontaktujte náš tým podpory.\n\n– Bezpečnostní tým {product_name}"
},
"account_scheduled_deletion": {
"subject": "Tvůj účet {product_name} bude trvale smazán",
"body": "Ahoj {username},\n\nTvůj účet {product_name} byl naplánován k trvalému smazání z důvodu porušení našich Podmínek služby nebo Pokynů komunity.\n\nNaplánované smazání: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nJedná se o závažné opatření. Tvá data účtu budou trvale smazána v naplánovaný den.\n\nZkontroluj prosím:\n- Podmínky služby: {termsUrl}\n- Pokyny komunity: {guidelinesUrl}\n\nProces odvolání:\nPokud se domníváš, že toto rozhodnutí o vynucení bylo nesprávné nebo neoprávněné, máš 60 dní na podání odvolání. Odešli e-mail na {appeals_email} z této e-mailové adresy.\n\nVe svém odvolání:\n- Jasně vysvětli, proč se domníváš, že rozhodnutí o vynucení bylo nesprávné nebo neoprávněné\n- Poskytni veškeré relevantní důkazy nebo kontext\n\nČlen bezpečnostního týmu {product_name} tvé odvolání přezkoumá a může pozastavit probíhající smazání, dokud nebude dosaženo konečného rozhodnutí.\n\n– Bezpečnostní tým {product_name}"
"subject": "Váš účet {product_name} bude trvale smazán",
"body": "Dobrý den, {username},\n\nKvůli porušení našich podmínek služby nebo komunitních pravidel bylo naplánováno trvalé smazání vašeho účtu {product_name}.\n\nNaplánované smazání: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nJedná se o závažné opatření. Data vašeho účtu budou v naplánovaný den trvale smazána.\n\nPřečtěte si:\n- Podmínky služby: {termsUrl}\n- Komunitní pravidla: {guidelinesUrl}\n\nPostup odvolání:\nPokud se domníváte, že toto rozhodnutí bylo nesprávné nebo neopodstatněné, máte 60 dní na podání odvolání. Pošlete e-mail na {appeals_email} z této e-mailové adresy.\n\nV odvolání:\n- Jasně vysvětlete, proč se domníváte, že rozhodnutí bylo nesprávné nebo neopodstatněné\n- Uveďte všechny relevantní důkazy a souvislosti\n\nČlen bezpečnostního týmu {product_name} vaše odvolání přezkoumá a může pozastavit plánované smazání, dokud nepadne konečné rozhodnutí.\n\n– Bezpečnostní tým {product_name}"
},
"account_temp_banned": {
"subject": "Tvůj účet {product_name} byl dočasně pozastaven",
"body": "Ahoj {username},\n\nTvůj účet {product_name} byl dočasně pozastaven za porušení našich Podmínek služby nebo Pokynů komunity.\n\nDoba trvání: {durationHours, plural,\n one {1 hodina} few {hodiny}\n many {hodiny} other {# hodin}\n}\nPozastaveno do: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nBěhem této doby nebudeš mít přístup ke svému účtu.\n\nZkontroluj prosím:\n- Podmínky služby: {termsUrl}\n- Pokyny komunity: {guidelinesUrl}\n\nPokud se domníváš, že toto rozhodnutí o vynucení bylo nesprávné nebo neoprávněné, můžeš podat odvolání. Odešli e-mail na {appeals_email} z této e-mailové adresy a jasně vysvětli, proč se domníváš, že rozhodnutí bylo nesprávné. Tvé odvolání přezkoumáme a odpovíme ti s naším rozhodnutím.\n\n– Bezpečnostní tým {product_name}"
"subject": "Váš účet {product_name} byl dočasně pozastaven",
"body": "Dobrý den, {username},\n\nVáš účet {product_name} byl dočasně pozastaven kvůli porušení našich podmínek služby nebo komunitních pravidel.\n\nDoba trvání: {durationHours, plural,\n one {# hodina}\n few {# hodiny}\n many {# hodiny}\n other {# hodin}\n}\nPozastaven do: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nBěhem této doby nebudete mít přístup ke svému účtu.\n\nPřečtěte si:\n- Podmínky služby: {termsUrl}\n- Komunitní pravidla: {guidelinesUrl}\n\nPokud se domníváte, že toto rozhodnutí bylo nesprávné nebo neopodstatněné, můžete podat odvolání. Pošlete e-mail na {appeals_email} z této e-mailové adresy a jasně vysvětlete, proč považujete rozhodnutí za nesprávné. Vaše odvolání přezkoumáme a sdělíme vám své rozhodnutí.\n\n– Bezpečnostní tým {product_name}"
},
"donation_confirmation": {
"subject": "Děkujeme za tvůj dar na {product_name}",
"body": "Ahoj,\n\nDěkujeme za tvůj dar pro {product_name}! Tvůj {interval, select,\n month {opakovaný dar}\n year {opakovaný dar}\n other {jednorázový dar}\n} byl {interval, select,\n month {nastaven}\n year {nastaven}\n other {zpracován}\n} úspěšně.\n\nPodrobnosti o daru:\nČástka: {amount} {currency} {interval, select,\n month {měsíčně}\n year {ročně}\n other {}\n}\n\nStripe ti brzy zašle samostatnou účtenku s PDF fakturou. Ta obsahuje všechny platební údaje a může být použita pro daňové účely.\n\nHistorii svých darů, stahování faktur {interval, select,\n month {a správu nebo zrušení předplatného}\n year {a správu nebo zrušení předplatného}\n other {a správu budoucích darů}\n} můžeš kdykoli zobrazit pomocí tohoto odkazu:\n\n{manageUrl}\n\nTvá podpora pomáhá udržovat {product_name} v chodu. Děkujeme!\n\n– Tým {product_name}"
"subject": "Děkujeme za váš dar pro {product_name}",
"body": "Dobrý den,\n\nDěkujeme za váš dar pro {product_name}! Váš {interval, select,\n month {opakovaný dar}\n year {opakovaný dar}\n other {jednorázový dar}\n} byl úspěšně {interval, select,\n month {nastaven}\n year {nastaven}\n other {zpracován}\n}.\n\nPodrobnosti o daru:\nČástka: {amount} {interval, select,\n month {měsíčně}\n year {ročně}\n other {}\n}\n\nStripe vám brzy pošle samostatné potvrzení platby s fakturou v PDF. Obsahuje všechny platební údaje a lze ho použít pro daňové účely.\n\nPomocí tohoto odkazu si můžete kdykoli prohlédnout historii svých darů, stáhnout faktury {interval, select,\n month {a spravovat nebo zrušit předplatné}\n year {a spravovat nebo zrušit předplatné}\n other {a spravovat budoucí dary}\n}:\n\n{manageUrl}\n\nVaše podpora pomáhá udržovat {product_name} v chodu. Děkujeme!\n\n– Tým {product_name}"
},
"donation_magic_link": {
"subject": "Spravuj své dary pro {product_name}",
"body": "Ahoj,\n\nKlikni na odkaz níže pro přístup k portálu pro dárce:\n\n{manageUrl}\n\nV portálu můžeš spravovat předplatná, stahovat faktury a prohlížet si historii svých darů.\n\nTento odkaz vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jsi tento odkaz nepožadoval, můžeš tento e-mail klidně ignorovat.\n\n– Tým {product_name}"
"subject": "Spravujte své dary pro {product_name}",
"body": "Dobrý den,\n\nKliknutím na odkaz níže otevřete portál pro dárce:\n\n{manageUrl}\n\nNa portálu můžete spravovat předplatná, stahovat faktury a prohlížet si historii svých darů.\n\nPlatnost tohoto odkazu vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jste si tento odkaz nevyžádali, můžete tento e-mail bez obav ignorovat.\n\n– Tým {product_name}"
},
"dsa_report_verification": {
"subject": "Ověř svůj e-mail pro nahlášení podle DSA",
"body": "Ahoj,\n\nPoužij ověřovací kód níže k odeslání svého nahlášení podle zákona o digitálních službách na {product_name}:\n\n{code}\n\nTento kód vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jsi o to nežádal, můžeš tento e-mail ignorovat.\n\n– Bezpečnostní tým {product_name}"
"subject": "Ověřte svůj e-mail pro nahlášení podle DSA",
"body": "Dobrý den,\n\nPomocí ověřovacího kódu níže odešlete své nahlášení podle nařízení o digitálních službách na {product_name}:\n\n{code}\n\nPlatnost tohoto kódu vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jste si tento kód nevyžádali, můžete tento e-mail ignorovat.\n\n– Bezpečnostní tým {product_name}"
},
"email_change_new": {
"subject": "Ověř svůj nový e-mail pro {product_name}",
"body": "Ahoj {username},\n\nZadej tento kód do aplikace pro ověření tvého nového e-mailu pro {product_name}:\n\n{code}\n\nTento kód vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jsi o to nežádal, můžeš tento e-mail ignorovat.\n\n– Tým {product_name}"
"subject": "Ověřte svou novou e-mailovou adresu pro {product_name}",
"body": "Dobrý den, {username},\n\nZadáním tohoto kódu v aplikaci ověříte svou novou e-mailovou adresu pro {product_name}:\n\n{code}\n\nPlatnost tohoto kódu vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jste si tento kód nevyžádali, můžete tento e-mail ignorovat.\n\n– Tým {product_name}"
},
"email_change_original": {
"subject": "Potvrď změnu e-mailu pro {product_name}",
"body": "Ahoj {username},\n\nObdrželi jsme požadavek na změnu e-mailové adresy tvého účtu {product_name}.\n\nPro potvrzení této změny zadej tento kód do aplikace:\n\n{code}\n\nTento kód vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jsi o to nežádal, zabezpeč si svůj účet ihned.\n\n– Tým {product_name}"
"subject": "Potvrďte změnu e-mailové adresy pro {product_name}",
"body": "Dobrý den, {username},\n\nObdrželi jsme žádost o změnu e-mailové adresy vašeho účtu {product_name}.\n\nZměnu potvrďte zadáním tohoto kódu v aplikaci:\n\n{code}\n\nPlatnost tohoto kódu vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jste o tuto změnu nežádali, ihned svůj účet zabezpečte.\n\n– Tým {product_name}"
},
"email_change_revert": {
"subject": "Tvůj e-mail pro {product_name} byl změněn",
"body": "Ahoj {username},\n\nE-mailová adresa tvého účtu {product_name} byla změněna na {newEmail}.\n\nPokud jsi tuto změnu provedl ty, není potřeba žádná akce. Pokud ne, můžeš změnu vrátit zpět a zabezpečit svůj účet pomocí tohoto odkazu:\n\n{revertUrl}\n\nTím se obnoví tvůj předchozí e-mail, odhlásíš se všude, odstraní se propojená telefonní čísla, deaktivuje se MFA a budeš muset nastavit nové heslo.\n\n– Bezpečnostní tým {product_name}"
"subject": "Vaše e-mailová adresa pro {product_name} byla změněna",
"body": "Dobrý den, {username},\n\nE-mailová adresa vašeho účtu {product_name} byla změněna na {newEmail}.\n\nPokud jste tuto změnu provedli vy, nemusíte nic dělat. Pokud ne, můžete ji vrátit zpět a zabezpečit svůj účet pomocí tohoto odkazu:\n\n{revertUrl}\n\nTím se obnoví vaše předchozí e-mailová adresa, budete odhlášeni ze všech zařízení, odstraní se propojená telefonní čísla, vypne se vícefaktorové ověřování a budete muset nastavit nové heslo.\n\n– Bezpečnostní tým {product_name}"
},
"email_verification": {
"subject": "Ověř svou e-mailovou adresu pro {product_name}",
"body": "Ahoj {username},\n\nOvěř e-mailovou adresu svého účtu {product_name} kliknutím na odkaz níže:\n\n{verifyUrl}\n\nPokud jsi si účet {product_name} nevytvořil, můžeš tento e-mail klidně ignorovat.\n\nTento odkaz je platný 24 hodin.\n\n– Tým {product_name}"
"subject": "Ověřte svou e-mailovou adresu pro {product_name}",
"body": "Dobrý den, {username},\n\nKliknutím na odkaz níže ověřte e-mailovou adresu svého účtu {product_name}:\n\n{verifyUrl}\n\nPokud jste si účet {product_name} nevytvořili, můžete tento e-mail bez obav ignorovat.\n\nTento odkaz je platný 24 hodin.\n\n– Tým {product_name}"
},
"gift_chargeback_notification": {
"subject": "Výhody z uplatněného dárku byly odstraněny",
"body": "Ahoj {username},\n\nDárkový kód, který jsi uplatnil, byl původně zaplacen někým jiným. Tato platba byla mezitím zrušena (chargeback).\n\nZ tohoto důvodu jsme odstranili výhody, které byly přidány k tvému účtu, když jsi dárek uplatnil.\n\nPokud si myslíš, že se jedná o chybu, kontaktuj prosím náš tým podpory a uveď veškeré podrobnosti, které máš o dárkovém kódu a o tom, kdy jsi ho uplatnil.\n\n– Tým {product_name}"
"body": "Dobrý den, {username},\n\nDárkový kód, který jste uplatnili, původně zaplatil někdo jiný. Tato platba byla následně vrácena na základě reklamace (chargeback).\n\nProto jsme odebrali výhody, které váš účet získal při uplatnění dárku.\n\nPokud se domníváte, že jde o chybu, kontaktujte náš tým podpory a uveďte všechny dostupné podrobnosti o dárkovém kódu a o tom, kdy jste ho uplatnili.\n\n– Tým {product_name}"
},
"harvest_completed": {
"subject": "Export tvých dat z {product_name} je připraven ke stažení",
"body": "Ahoj {username},\n\nTvůj export dat je připraven.\n\nOdkaz ke stažení:\n{downloadUrl}\n\nZahrnuté zprávy: {totalMessages, number}\nVelikost souboru: {fileSizeMB, number} MB\n\nTento odkaz vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jsi o tento export nežádal, okamžitě si změň heslo a kontaktuj náš tým podpory.\n\n– Tým {product_name}"
"subject": "Export vašich dat z {product_name} je připraven ke stažení",
"body": "Dobrý den, {username},\n\nExport vašich dat je připraven.\n\nOdkaz ke stažení:\n{downloadUrl}\n\nPočet zahrnutých zpráv: {totalMessages, number}\nVelikost souboru: {fileSizeMB, number} MB\n\nPlatnost tohoto odkazu vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jste si tento export nevyžádali, okamžitě si změňte heslo a kontaktujte náš tým podpory.\n\n– Tým {product_name}"
},
"inactivity_warning": {
"subject": "Tvůj účet {product_name} bude smazán kvůli neaktivitě",
"body": "Ahoj {username},\n\nNezaznamenali jsme žádnou aktivitu na tvém účtu {product_name} od {lastActiveDate, date, full}.\n\nPokud se nepřihlásíš do {deletionDate, date, full} v {deletionDate, time, short}, tvůj účet bude trvale smazán kvůli neaktivitě.\n\nPřihlas se zde:\n{loginUrl}\n\nPokud jsi {product_name} nedávno používal, kontaktuj náš tým podpory ihned.\n\n– Tým {product_name}"
"subject": "Váš účet {product_name} bude smazán kvůli neaktivitě",
"body": "Dobrý den, {username},\n\nOd {lastActiveDate, date, full} jsme na vašem účtu {product_name} nezaznamenali žádnou aktivitu.\n\nPokud se nepřihlásíte do {deletionDate, date, full} v {deletionDate, time, short}, váš účet bude kvůli neaktivitě trvale smazán.\n\nPřihlaste se zde:\n{loginUrl}\n\nPokud jste {product_name} v poslední době používali, ihned kontaktujte náš tým podpory.\n\n– Tým {product_name}"
},
"ip_authorization": {
"subject": "Povolit přihlášení z nové IP adresy",
"body": "Ahoj {username},\n\nZaznamenali jsme pokus o přihlášení k tvému účtu {product_name} z nové IP adresy:\n\nIP adresa: {ipAddress}\nPoloha: {location}\n\nPokud jsi to byl ty, autorizuj tuto IP adresu kliknutím na odkaz níže:\n\n{authUrl}\n\nPokud jsi se nepřihlašoval, okamžitě si změň heslo.\n\nTento odkaz je platný 30 minut.\n\n– Tým {product_name}"
"body": "Dobrý den, {username},\n\nZaznamenali jsme pokus o přihlášení k vašemu účtu {product_name} z nové IP adresy:\n\nIP adresa: {ipAddress}\nPoloha: {location}\n\nPokud jste to byli vy, povolte tuto IP adresu kliknutím na odkaz níže:\n\n{authUrl}\n\nPokud jste se nepokoušeli přihlásit, ihned si změňte heslo.\n\nTento odkaz je platný 30 minut.\n\n– Tým {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Potvrď přístup k záložním kódům pro {product_name}",
"body": "Ahoj {username},\n\nObdrželi jsme požadavek na zobrazení záložních kódů tvého účtu {product_name}.\n\nPro potvrzení tohoto požadavku zadej tento kód do aplikace:\n\n{code}\n\nTento kód vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jsi o to nežádal, někdo může mít přístup k tvému účtu. Okamžitě si změň heslo.\n\n– Tým {product_name}"
"subject": "Potvrďte přístup k záložním kódům pro {product_name}",
"body": "Dobrý den, {username},\n\nObdrželi jsme žádost o zobrazení záložních kódů vašeho účtu {product_name}.\n\nŽádost potvrďte zadáním tohoto kódu v aplikaci:\n\n{code}\n\nPlatnost tohoto kódu vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jste o to nežádali, někdo může mít přístup k vašemu účtu. Okamžitě si změňte heslo.\n\n– Tým {product_name}"
},
"password_change_verification": {
"subject": "Potvrď změnu hesla k {product_name}",
"body": "Ahoj {username},\n\nObdrželi jsme požadavek na změnu hesla k tvému účtu pro {product_name}.\n\nPro potvrzení této změny zadej tento kód do aplikace:\n\n{code}\n\nTento kód vyprší v {expiresAt}.\n\nPokud jsi o to nežádal, někdo může mít přístup k tvému účtu. Okamžitě si změň heslo a povol dvoufaktorové ověřování.\n\n– Tým {product_name}"
"subject": "Potvrďte změnu hesla k účtu {product_name}",
"body": "Dobrý den, {username},\n\nObdrželi jsme žádost o změnu hesla k vašemu účtu {product_name}.\n\nZměnu potvrďte zadáním tohoto kódu v aplikaci:\n\n{code}\n\nPlatnost tohoto kódu vyprší v {expiresAt}.\n\nPokud jste o to nežádali, někdo může mít přístup k vašemu účtu. Okamžitě si změňte heslo a zapněte dvoufaktorové ověřování.\n\n– Tým {product_name}"
},
"password_reset": {
"subject": "Obnov své heslo k {product_name}",
"body": "Ahoj {username},\n\nPožádal jsi o reset hesla pro {product_name}. Použij odkaz níže pro nastavení nového hesla:\n\n{resetUrl}\n\nPokud jsi o to nežádal, můžeš tento e-mail klidně ignorovat.\n\nTento odkaz je platný 1 hodinu.\n\n– Tým {product_name}"
"subject": "Obnovte své heslo k účtu {product_name}",
"body": "Dobrý den, {username},\n\nPožádali jste o obnovení hesla k účtu {product_name}. Pomocí odkazu níže si nastavte nové heslo:\n\n{resetUrl}\n\nPokud jste o to nežádali, můžete tento e-mail bez obav ignorovat.\n\nTento odkaz je platný 1 hodinu.\n\n– Tým {product_name}"
},
"registration_approved": {
"subject": "Tvá registrace k {product_name} byla schválena",
"body": "Ahoj {username},\n\nDobré zprávy: tvá registrace do {product_name} byla schválena.\n\nNyní se můžeš přihlásit do aplikace {product_name} zde:\n{channelsUrl}\n\nVítej v komunitě {product_name}.\n\n– Tým {product_name}"
"subject": "Vaše registrace do {product_name} byla schválena",
"body": "Dobrý den, {username},\n\nDobrá zpráva: vaše registrace do {product_name} byla schválena.\n\nNyní se můžete přihlásit do aplikace {product_name} zde:\n{channelsUrl}\n\nVítejte v komunitě {product_name}.\n\n– Tým {product_name}"
},
"report_resolved": {
"subject": "Tvé nahlášení pro {product_name} bylo zkontrolováno",
"body": "Ahoj {username},\n\nTvé nahlášení (ID: {reportId}) bylo zkontrolováno naším bezpečnostním týmem.{hasComment, select, yes {\n\nOdpověď od bezpečnostního týmu:\n{publicComment}} other {}}\n\nDěkujeme, že pomáháš udržovat {product_name} bezpečný pro všechny. Všechna nahlášení bereme vážně a oceňujeme tvůj příspěvek komunitě.\n\nPokud máš jakékoli dotazy nebo obavy ohledně tohoto výsledku, kontaktuj {safety_email}.\n\n– Bezpečnostní tým {product_name}"
"subject": "Vaše nahlášení na {product_name} bylo přezkoumáno",
"body": "Dobrý den, {username},\n\nNáš bezpečnostní tým přezkoumal vaše nahlášení (ID: {reportId}).{hasComment, select, yes {\n\nOdpověď bezpečnostního týmu:\n{publicComment}} other {}}\n\nDěkujeme, že pomáháte udržovat {product_name} bezpečný pro všechny. Všechna nahlášení bereme vážně a vážíme si vašeho přínosu komunitě.\n\nPokud máte k tomuto výsledku jakékoli dotazy nebo výhrady, kontaktujte {safety_email}.\n\n– Bezpečnostní tým {product_name}"
},
"scheduled_deletion_notification": {
"subject": "Tvůj účet {product_name} bude trvale smazán",
"body": "Ahoj {username},\n\nTvůj účet {product_name} byl naplánován k trvalému smazání.\n\nNaplánované smazání: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nJedná se o závažné opatření. Tvá data účtu budou trvale smazána v naplánovaný den.\n\nPokud se domníváš, že toto rozhodnutí o vynucení bylo nesprávné, můžeš podat odvolání. Odešli e-mail na {appeals_email} z této e-mailové adresy.\n\n– Bezpečnostní tým {product_name}"
"subject": "Váš účet {product_name} bude trvale smazán",
"body": "Dobrý den, {username},\n\nBylo naplánováno trvalé smazání vašeho účtu {product_name}.\n\nNaplánované smazání: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nJedná se o závažné opatření. Data vašeho účtu budou v naplánovaný den trvale smazána.\n\nPokud se domníváte, že toto rozhodnutí bylo nesprávné, můžete podat odvolání. Pošlete e-mail na {appeals_email} z této e-mailové adresy.\n\n– Bezpečnostní tým {product_name}"
},
"self_deletion_scheduled": {
"subject": "Smazání tvého účtu {product_name} je naplánováno",
"body": "Ahoj {username},\n\nPožádal jsi o smazání svého účtu {product_name}. Tvůj účet je naplánován k trvalému smazání na:\n\n{deletionDate, date, full} v {deletionDate, time, short}\n\nPokud jsi to nepožadoval, přihlas se ke svému účtu a zruš smazání. Doporučujeme také změnit si heslo pro zabezpečení účtu.\n\n– Tým {product_name}"
"subject": "Smazání vašeho účtu {product_name} je naplánováno",
"body": "Dobrý den, {username},\n\nPožádali jste o smazání svého účtu {product_name}. Trvalé smazání je naplánováno na:\n\n{deletionDate, date, full} v {deletionDate, time, short}\n\nPokud jste o smazání nežádali, přihlaste se ke svému účtu a smazání zrušte. Doporučujeme také změnit heslo, abyste svůj účet zabezpečili.\n\n– Tým {product_name}"
},
"unban_notification": {
"subject": "Pozastavení tvého účtu {product_name} bylo zrušeno",
"body": "Ahoj {username},\n\nDobré zprávy: pozastavení tvého účtu {product_name} bylo zrušeno.\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nNyní se můžeš znovu přihlásit a pokračovat v používání {product_name} jako obvykle.\n\n– Bezpečnostní tým {product_name}"
"subject": "Pozastavení vašeho účtu {product_name} bylo zrušeno",
"body": "Dobrý den, {username},\n\nDobrá zpráva: pozastavení vašeho účtu {product_name} bylo zrušeno.\n\n{reason, select,\n null {}\n other {Důvod: {reason}}\n}\n\nNyní se můžete znovu přihlásit a používat {product_name} jako obvykle.\n\n– Bezpečnostní tým {product_name}"
}
});
@@ -1,95 +1,95 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
const EMAIL_I18N_DA_MESSAGES = defineEmailI18nLocaleMessages({
"account_disabled_suspicious": {
"subject": "Din {product_name}-konto er midlertidigt deaktiveret",
"body": "Hej {username},\n\nVi har midlertidigt deaktiveret din {product_name}-konto, fordi vi har registreret mistænkelig aktivitet.\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nFor at få adgang til din konto igen skal du nulstille din adgangskode:\n\n{forgotUrl}\n\nNår du har nulstillet din adgangskode, kan du logge ind igen.\n\nHvis du mener, at dette er sket ved en fejl, kan du kontakte vores supportteam.\n\n– {product_name} Safety Team"
"body": "Hej {username},\n\nVi har midlertidigt deaktiveret din {product_name}-konto, fordi vi har registreret mistænkelig aktivitet.\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nFor at få adgang til din konto igen skal du nulstille din adgangskode:\n\n{forgotUrl}\n\nNår du har nulstillet din adgangskode, kan du logge ind igen.\n\nHvis du mener, at dette er sket ved en fejl, kan du kontakte vores supportteam.\n\n– Sikkerhedsteamet hos {product_name}"
},
"account_scheduled_deletion": {
"subject": "Din {product_name}-konto slettes permanent",
"body": "Hej {username},\n\nDin {product_name}-konto er planlagt til permanent sletning på grund af overtrædelser af vores servicevilkår eller retningslinjer for community'et.\n\nPlanlagt sletning: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nDette er en alvorlig håndhævelseshandling. Dine kontodata bliver permanent slettet på den planlagte dato.\n\nGennemgå:\n- Servicevilkår: {termsUrl}\n- Retningslinjer for community'et: {guidelinesUrl}\n\nAnkeproces:\nHvis du mener, at denne håndhævelsesbeslutning var forkert eller uberettiget, har du 60 dage til at indsende en anke. Send en e-mail til {appeals_email} fra denne e-mailadresse.\n\nI din anke:\n- Forklar tydeligt, hvorfor du mener, at håndhævelsesbeslutningen var forkert eller uberettiget\n- Fremlæg relevant bevis eller kontekst\n\nEt medlem af {product_name} Safety Team vil gennemgå din anke og kan sætte den afventende sletning på pause, indtil en endelig beslutning er truffet.\n\n– {product_name} Safety Team"
"body": "Hej {username},\n\nVi har planlagt at slette din {product_name}-konto permanent på grund af overtrædelser af vores servicevilkår eller retningslinjer for fællesskabet.\n\nPlanlagt sletning: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nDette er en alvorlig sanktion. Dine kontodata bliver permanent slettet på den planlagte dato.\n\nGennemgå:\n- Servicevilkår: {termsUrl}\n- Retningslinjer for fællesskabet: {guidelinesUrl}\n\nKlageproces:\nHvis du mener, at denne beslutning om sanktioner var forkert eller uberettiget, har du 60 dage til at indsende en klage. Send en e-mail til {appeals_email} fra denne e-mailadresse.\n\nI din klage:\n- Forklar tydeligt, hvorfor du mener, at beslutning om sanktioneren var forkert eller uberettiget\n- Fremlæg relevant dokumentation eller kontekst\n\nEt medlem af sikkerhedsteamet hos {product_name} vil gennemgå din klage og kan sætte den afventende sletning på pause, indtil en endelig beslutning er truffet.\n\n– Sikkerhedsteamet hos {product_name}"
},
"account_temp_banned": {
"subject": "Din {product_name}-konto er midlertidigt suspenderet",
"body": "Hej {username},\n\nDin {product_name}-konto er midlertidigt suspenderet for at overtræde vores servicevilkår eller retningslinjer for community'et.\n\nVarighed: {durationHours, plural,\n =1 {1 time}\n other {# timer}\n}\nSuspenderet indtil: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nI denne periode vil du ikke kunne få adgang til din konto.\n\nGennemgå:\n- Servicevilkår: {termsUrl}\n- Retningslinjer for community'et: {guidelinesUrl}\n\nHvis du mener, at denne håndhævelsesbeslutning var forkert eller uberettiget, kan du indsende en anke. Send en e-mail til {appeals_email} fra denne e-mailadresse og forklar tydeligt, hvorfor du mener, at beslutningen var forkert. Vi vil gennemgå din anke og svare med vores beslutning.\n\n– {product_name} Safety Team"
"body": "Hej {username},\n\nDin {product_name}-konto er midlertidigt suspenderet på grund af overtrædelser af vores servicevilkår eller retningslinjer for fællesskabet.\n\nVarighed: {durationHours, plural,\n =1 {1 time}\n other {# timer}\n}\nSuspenderet indtil: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nI denne periode vil du ikke kunne få adgang til din konto.\n\nGennemgå:\n- Servicevilkår: {termsUrl}\n- Retningslinjer for fællesskabet: {guidelinesUrl}\n\nHvis du mener, at denne beslutning om sanktioner var forkert eller uberettiget, kan du indsende en klage. Send en e-mail til {appeals_email} fra denne e-mailadresse og forklar tydeligt, hvorfor du mener, at beslutningen var forkert. Vi gennemgår din klage og giver dig besked om vores afgørelse.\n\n– Sikkerhedsteamet hos {product_name}"
},
"donation_confirmation": {
"subject": "Tak for din {product_name}-donation",
"body": "Hej,\n\nTak for din donation til {product_name}! Din {interval, select,\n month {tilbagevendende donation}\n year {tilbagevendende donation}\n other {engangsdonation}\n} er blevet {interval, select,\n month {opsat}\n year {opsat}\n other {behandlet}\n} med succes.\n\nDonationsdetaljer:\nBeløb: {amount} {currency} {interval, select,\n month {pr. måned}\n year {pr. år}\n other {}\n}\n\nStripe sender dig en separat kvittering med din faktura-PDF inden længe. Denne inkluderer alle betalingsdetaljer og kan bruges til skatteformål.\n\nDu kan til enhver tid se din donationshistorik, downloade fakturaer, {interval, select,\n month {og administrere eller annullere dit abonnement}\n year {og administrere eller annullere dit abonnement}\n other {og administrere fremtidige donationer}\n} ved at bruge dette link:\n\n{manageUrl}\n\nDin støtte hjælper med at holde {product_name} kørende. Tak!\n\n– {product_name} Team"
"body": "Hej,\n\nTak for din donation til {product_name}! Din {interval, select,\n month {løbende donation}\n year {løbende donation}\n other {engangsdonation}\n} er blevet {interval, select,\n month {oprettet}\n year {oprettet}\n other {gennemført}\n}.\n\nDonationsdetaljer:\nBeløb: {amount} {interval, select,\n month {pr. måned}\n year {pr. år}\n other {}\n}\n\nStripe sender dig en separat kvittering med din faktura som PDF inden længe. Kvitteringen indeholder alle betalingsdetaljer og kan bruges til skatteformål.\n\nDu kan til enhver tid se din donationshistorik, downloade fakturaer {interval, select,\n month {og administrere eller opsige dit abonnement}\n year {og administrere eller opsige dit abonnement}\n other {og administrere fremtidige donationer}\n} ved at bruge dette link:\n\n{manageUrl}\n\nDin støtte hjælper med at holde {product_name} kørende. Tak!\n\n– Teamet bag {product_name}"
},
"donation_magic_link": {
"subject": "Administrer dine {product_name}-donationer",
"body": "Hej,\n\nKlik på linket nedenfor for at få adgang til din donorportal:\n\n{manageUrl}\n\nI portalen kan du administrere abonnementer, downloade fakturaer og se din donationshistorik.\n\nDette link udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette link, kan du trygt ignorere denne e-mail.\n\n– {product_name} Team"
"body": "Hej,\n\nKlik på linket nedenfor for at få adgang til din donorportal:\n\n{manageUrl}\n\nI portalen kan du administrere abonnementer, downloade fakturaer og se din donationshistorik.\n\nDette link udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette link, kan du trygt ignorere denne e-mail.\n\n– Teamet bag {product_name}"
},
"dsa_report_verification": {
"subject": "Bekræft din e-mail til en DSA-rapport",
"body": "Hej,\n\nBrug bekræftelseskoden nedenfor til at indsende din Digital Services Act-rapport for {product_name}:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, kan du ignorere denne e-mail.\n\n– {product_name} Safety Team"
"subject": "Bekræft din e-mailadresse til en DSA-anmeldelse",
"body": "Hej,\n\nBrug bekræftelseskoden nedenfor til at indsende din anmeldelse i henhold til Digital Services Act på {product_name}:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, kan du ignorere denne e-mail.\n\n– Sikkerhedsteamet hos {product_name}"
},
"email_change_new": {
"subject": "Bekræft din nye {product_name}-e-mail",
"body": "Hej {username},\n\nIndtast denne kode i appen for at bekræfte din nye {product_name}-e-mail:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, kan du ignorere denne e-mail.\n\n– {product_name} Team"
"subject": "Bekræft din nye e-mailadresse til {product_name}",
"body": "Hej {username},\n\nIndtast denne kode i appen for at bekræfte din nye e-mailadresse til {product_name}:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, kan du ignorere denne e-mail.\n\n– Teamet bag {product_name}"
},
"email_change_original": {
"subject": "Bekræft din {product_name}-e-mail-ændring",
"body": "Hej {username},\n\nVi har modtaget en anmodning om at ændre e-mailadressen på din {product_name}-konto.\n\nFor at bekræfte denne ændring skal du indtaste denne kode i appen:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, skal du sikre din konto med det samme.\n\n– {product_name} Team"
"subject": "Bekræft ændringen af din e-mailadresse til {product_name}",
"body": "Hej {username},\n\nVi har modtaget en anmodning om at ændre e-mailadressen på din {product_name}-konto.\n\nFor at bekræfte denne ændring skal du indtaste denne kode i appen:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, skal du sikre din konto med det samme.\n\n– Teamet bag {product_name}"
},
"email_change_revert": {
"subject": "Din {product_name}-e-mail blev ændret",
"body": "Hej {username},\n\nE-mailadressen på din {product_name}-konto blev ændret til {newEmail}.\n\nHvis du har foretaget denne ændring, er der ingen handling nødvendig. Hvis du ikke har, kan du fortryde ændringen og sikre din konto ved at bruge dette link:\n\n{revertUrl}\n\nDette vil gendanne din tidligere e-mail, logge dig ud overalt, fjerne tilknyttede telefonnumre, deaktivere MFA og kræve, at du indstiller en ny adgangskode.\n\n– {product_name} Safety Team"
"subject": "Din e-mailadresse til {product_name} er ændret",
"body": "Hej {username},\n\nE-mailadressen på din {product_name}-konto blev ændret til {newEmail}.\n\nHvis du selv har foretaget ændringen, behøver du ikke gøre noget. Ellers kan du fortryde ændringen og sikre din konto ved at bruge dette link:\n\n{revertUrl}\n\nDette vil gendanne din tidligere e-mailadresse, logge dig ud overalt, fjerne tilknyttede telefonnumre, slå multifaktorgodkendelse fra og kræve, at du vælger en ny adgangskode.\n\n– Sikkerhedsteamet hos {product_name}"
},
"email_verification": {
"subject": "Bekræft din {product_name}-e-mailadresse",
"body": "Hej {username},\n\nBekræft e-mailadressen for din {product_name}-konto ved at klikke på linket nedenfor:\n\n{verifyUrl}\n\nHvis du ikke har oprettet en {product_name}-konto, kan du trygt ignorere denne e-mail.\n\nDette link er gyldigt i 24 timer.\n\n– {product_name} Team"
"body": "Hej {username},\n\nBekræft e-mailadressen for din {product_name}-konto ved at klikke på linket nedenfor:\n\n{verifyUrl}\n\nHvis du ikke har oprettet en {product_name}-konto, kan du trygt ignorere denne e-mail.\n\nDette link er gyldigt i 24 timer.\n\n– Teamet bag {product_name}"
},
"gift_chargeback_notification": {
"subject": "Fordele fra din indløste gave er fjernet",
"body": "Hej {username},\n\nEn gavekode, du indløste, blev oprindeligt betalt af en anden. Den betaling er siden annulleret (en tilbageførsel).\n\nDerfor har vi fjernet de fordele, der blev tilføjet din konto, da du indløste gaven.\n\nHvis du mener, at dette er en fejl, kan du kontakte vores supportteam og oplyse alle detaljer, du har om gavekoden, og hvornår du indløste den.\n\n– {product_name} Team"
"body": "Hej {username},\n\nEn gavekode, du indløste, blev oprindeligt betalt af en anden. Den betaling er siden annulleret (en tilbageførsel).\n\nDerfor har vi fjernet de fordele, der blev tilføjet din konto, da du indløste gaven.\n\nHvis du mener, at dette er en fejl, kan du kontakte vores supportteam og oplyse alle detaljer, du har om gavekoden, og hvornår du indløste den.\n\n– Teamet bag {product_name}"
},
"harvest_completed": {
"subject": "Din {product_name}-dataeksport er klar til at downloade",
"body": "Hej {username},\n\nDin dataeksport er klar.\n\nDownload-link:\n{downloadUrl}\n\nAntal beskeder: {totalMessages, number}\nFilstørrelse: {fileSizeMB, number} MB\n\nDette link udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om denne eksport, skal du straks ændre din adgangskode og kontakte vores supportteam.\n\n– {product_name} Team"
"subject": "Din dataeksport fra {product_name} er klar til download",
"body": "Hej {username},\n\nDin dataeksport er klar.\n\nDownload-link:\n{downloadUrl}\n\nAntal beskeder: {totalMessages, number}\nFilstørrelse: {fileSizeMB, number} MB\n\nDette link udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om denne eksport, skal du straks ændre din adgangskode og kontakte vores supportteam.\n\n– Teamet bag {product_name}"
},
"inactivity_warning": {
"subject": "Din {product_name}-konto slettes på grund af inaktivitet",
"body": "Hej {username},\n\nVi har ikke set nogen aktivitet på din {product_name}-konto siden {lastActiveDate, date, full}.\n\nHvis du ikke logger ind inden {deletionDate, date, full} kl. {deletionDate, time, short}, slettes din konto permanent på grund af inaktivitet.\n\nLog ind her:\n{loginUrl}\n\nHvis du har brugt {product_name} for nylig, skal du kontakte vores supportteam med det samme.\n\n– {product_name} Team"
"body": "Hej {username},\n\nVi har ikke set nogen aktivitet på din {product_name}-konto siden {lastActiveDate, date, full}.\n\nHvis du ikke logger ind inden {deletionDate, date, full} kl. {deletionDate, time, short}, slettes din konto permanent på grund af inaktivitet.\n\nLog ind her:\n{loginUrl}\n\nHvis du har brugt {product_name} for nylig, skal du kontakte vores supportteam med det samme.\n\n– Teamet bag {product_name}"
},
"ip_authorization": {
"subject": "Godkend login fra en ny IP-adresse",
"body": "Hej {username},\n\nVi har registreret et login-forsøg på din {product_name}-konto fra en ny IP-adresse:\n\nIP-adresse: {ipAddress}\nSted: {location}\n\nHvis dette var dig, skal du godkende denne IP-adresse ved at klikke på linket nedenfor:\n\n{authUrl}\n\nHvis du ikke har forsøgt at logge ind, skal du straks ændre din adgangskode.\n\nDette link er gyldigt i 30 minutter.\n\n– {product_name} Team"
"body": "Hej {username},\n\nVi har registreret et loginforsøg på din {product_name}-konto fra en ny IP-adresse:\n\nIP-adresse: {ipAddress}\nSted: {location}\n\nHvis det var dig, skal du godkende denne IP-adresse ved at klikke på linket nedenfor:\n\n{authUrl}\n\nHvis du ikke har forsøgt at logge ind, skal du straks ændre din adgangskode.\n\nDette link er gyldigt i 30 minutter.\n\n– Teamet bag {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Bekræft adgang til dine {product_name}-backupkoder",
"body": "Hej {username},\n\nVi har modtaget en anmodning om at se backupkoderne på din {product_name}-konto.\n\nFor at bekræfte denne anmodning skal du indtaste denne kode i appen:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, har nogen muligvis adgang til din konto. Skift din adgangskode med det samme.\n\n– {product_name} Team"
"body": "Hej {username},\n\nVi har modtaget en anmodning om at se backupkoderne på din {product_name}-konto.\n\nFor at bekræfte denne anmodning skal du indtaste denne kode i appen:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, har nogen muligvis adgang til din konto. Skift din adgangskode med det samme.\n\n– Teamet bag {product_name}"
},
"password_change_verification": {
"subject": "Bekræft din {product_name}-adgangskodeændring",
"body": "Hej {username},\n\nVi har modtaget en anmodning om at ændre adgangskoden på din {product_name}-konto.\n\nFor at bekræfte denne ændring skal du indtaste denne kode i appen:\n\n{code}\n\nDenne kode udløber kl. {expiresAt}.\n\nHvis du ikke har anmodet om dette, har nogen muligvis adgang til din konto. Skift din adgangskode med det samme, og aktiver totrinsgodkendelse.\n\n– {product_name} Team"
"subject": "Bekræft ændringen af din adgangskode til {product_name}",
"body": "Hej {username},\n\nVi har modtaget en anmodning om at ændre adgangskoden på din {product_name}-konto.\n\nFor at bekræfte denne ændring skal du indtaste denne kode i appen:\n\n{code}\n\nDenne kode udløber kl. {expiresAt}.\n\nHvis du ikke har anmodet om dette, har nogen muligvis adgang til din konto. Skift din adgangskode med det samme, og aktiver tofaktorgodkendelse.\n\n– Teamet bag {product_name}"
},
"password_reset": {
"subject": "Nulstil din {product_name}-adgangskode",
"body": "Hej {username},\n\nDu har anmodet om at nulstille din {product_name}-adgangskode. Brug linket nedenfor til at indstille en ny adgangskode:\n\n{resetUrl}\n\nHvis du ikke har anmodet om dette, kan du trygt ignorere denne e-mail.\n\nDette link er gyldigt i 1 time.\n\n– {product_name} Team"
"body": "Hej {username},\n\nDu har anmodet om at nulstille din {product_name}-adgangskode. Brug linket nedenfor til at vælge en ny adgangskode:\n\n{resetUrl}\n\nHvis du ikke har anmodet om dette, kan du trygt ignorere denne e-mail.\n\nDette link er gyldigt i 1 time.\n\n– Teamet bag {product_name}"
},
"registration_approved": {
"subject": "Din {product_name}-registrering er godkendt",
"body": "Hej {username},\n\nGode nyheder: Din {product_name}-registrering er godkendt.\n\nDu kan nu logge ind på {product_name}-appen her:\n{channelsUrl}\n\nVelkommen til {product_name}-community'et.\n\n– {product_name} Team"
"subject": "Din tilmelding til {product_name} er godkendt",
"body": "Hej {username},\n\nGode nyheder: Din tilmelding til {product_name} er godkendt.\n\nDu kan nu logge ind på {product_name}-appen her:\n{channelsUrl}\n\nVelkommen til fællesskabet på {product_name}.\n\n– Teamet bag {product_name}"
},
"report_resolved": {
"subject": "Din {product_name}-rapport er gennemgået",
"body": "Hej {username},\n\nDin rapport (ID: {reportId}) er gennemgået af vores Safety Team.{hasComment, select, yes {\n\nSvar fra Safety Team:\n{publicComment}} other {}}\n\nTak for din hjælp til at holde {product_name} sikkert for alle. Vi tager alle rapporter alvorligt og værdsætter dit bidrag til community'et.\n\nHvis du har spørgsmål eller bekymringer vedrørende dette resultat, kan du kontakte {safety_email}.\n\n– {product_name} Safety Team"
"subject": "Din anmeldelse på {product_name} er blevet behandlet",
"body": "Hej {username},\n\nDin anmeldelse (ID: {reportId}) er blevet behandlet af vores sikkerhedsteam.{hasComment, select, yes {\n\nSvar fra sikkerhedsteamet:\n{publicComment}} other {}}\n\nTak for din hjælp til at holde {product_name} sikkert for alle. Vi tager alle anmeldelser alvorligt og værdsætter dit bidrag til fællesskabet.\n\nHvis du har spørgsmål eller bekymringer vedrørende dette resultat, kan du kontakte {safety_email}.\n\n– Sikkerhedsteamet hos {product_name}"
},
"scheduled_deletion_notification": {
"subject": "Din {product_name}-konto slettes permanent",
"body": "Hej {username},\n\nDin {product_name}-konto er planlagt til permanent sletning.\n\nPlanlagt sletning: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nDette er en alvorlig håndhævelseshandling. Dine kontodata bliver permanent slettet på den planlagte dato.\n\nHvis du mener, at denne håndhævelsesbeslutning var forkert, kan du indsende en anke. Send en e-mail til {appeals_email} fra denne e-mailadresse.\n\n– {product_name} Safety Team"
"body": "Hej {username},\n\nVi har planlagt at slette din {product_name}-konto permanent.\n\nPlanlagt sletning: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nDette er en alvorlig sanktion. Dine kontodata bliver permanent slettet på den planlagte dato.\n\nHvis du mener, at denne beslutning om sanktioner var forkert, kan du indsende en klage. Send en e-mail til {appeals_email} fra denne e-mailadresse.\n\n– Sikkerhedsteamet hos {product_name}"
},
"self_deletion_scheduled": {
"subject": "Din {product_name}-kontosletning er planlagt",
"body": "Hej {username},\n\nDu har anmodet om at slette din {product_name}-konto. Din konto slettes permanent den:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}\n\nHvis du ikke har anmodet om dette, skal du logge ind på din konto for at annullere sletningen. Vi anbefaler også, at du ændrer din adgangskode for at sikre din konto.\n\n– {product_name} Team"
"subject": "Sletningen af din {product_name}-konto er planlagt",
"body": "Hej {username},\n\nDu har anmodet om at slette din {product_name}-konto. Din konto slettes permanent den:\n\n{deletionDate, date, full} kl. {deletionDate, time, short}\n\nHvis du ikke har anmodet om dette, skal du logge ind på din konto for at annullere sletningen. Vi anbefaler også, at du ændrer din adgangskode for at sikre din konto.\n\n– Teamet bag {product_name}"
},
"unban_notification": {
"subject": "Din {product_name}-kontosuspension er ophævet",
"body": "Hej {username},\n\nGode nyheder: Din {product_name}-kontosuspension er ophævet.\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nDu kan nu logge ind igen og fortsætte med at bruge {product_name} som normalt.\n\n– {product_name} Safety Team"
"subject": "Suspensionen af din {product_name}-konto er ophævet",
"body": "Hej {username},\n\nGode nyheder: Suspensionen af din {product_name}-konto er ophævet.\n\n{reason, select,\n null {}\n other {Årsag: {reason}}\n}\n\nDu kan nu logge ind igen og fortsætte med at bruge {product_name} som normalt.\n\n– Sikkerhedsteamet hos {product_name}"
}
});
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
"account_disabled_suspicious": {
@@ -9,7 +9,7 @@ const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
},
"account_scheduled_deletion": {
"subject": "Dein {product_name}-Account wird dauerhaft gelöscht",
"body": "Hallo {username},\n\ndein {product_name}-Account wurde aufgrund von Verstößen gegen unsere Nutzungsbedingungen oder Community-Richtlinien zur dauerhaften Löschung vorgemerkt.\n\nGeplante Löschung: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nDies ist eine ernsthafte Maßnahme. Deine Accountdaten werden am geplanten Datum dauerhaft gelöscht.\n\nBitte lies dir Folgendes durch:\n- Nutzungsbedingungen: {termsUrl}\n- Community-Richtlinien: {guidelinesUrl}\n\nEinspruchsverfahren:\nWenn du glaubst, dass diese Entscheidung falsch oder ungerechtfertigt war, hast du 60 Tage Zeit, Einspruch einzulegen. Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email}.\n\nIn deinem Einspruch:\n- Erkläre klar, warum du glaubst, dass die Entscheidung falsch oder ungerechtfertigt war\n- Füge alle relevanten Beweise oder Kontextinformationen bei\n\nEin Mitglied des {product_name}-Sicherheitsteams wird deinen Einspruch prüfen und die ausstehende Löschung möglicherweise pausieren, bis eine endgültige Entscheidung getroffen wurde.\n\n– {product_name}-Sicherheitsteam"
"body": "Hallo {username},\n\ndein {product_name}-Account wurde aufgrund von Verstößen gegen unsere Nutzungsbedingungen oder Community-Richtlinien zur dauerhaften Löschung vorgemerkt.\n\nGeplante Löschung: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nDies ist eine ernsthafte Maßnahme. Deine Accountdaten werden am geplanten Datum dauerhaft gelöscht.\n\nBitte lies dir Folgendes durch:\n- Nutzungsbedingungen: {termsUrl}\n- Community-Richtlinien: {guidelinesUrl}\n\nEinspruchsverfahren:\nWenn du glaubst, dass diese Entscheidung falsch oder ungerechtfertigt war, hast du 60 Tage Zeit, Einspruch einzulegen. Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email}.\n\nIn deinem Einspruch:\n- Erkläre klar, warum du glaubst, dass die Entscheidung falsch oder ungerechtfertigt war\n- Füge alle relevanten Beweise oder Kontextinformationen bei\n\nEin Mitglied des {product_name}-Sicherheitsteams wird deinen Einspruch prüfen und die geplante Löschung möglicherweise aussetzen, bis eine endgültige Entscheidung getroffen wurde.\n\n– {product_name}-Sicherheitsteam"
},
"account_temp_banned": {
"subject": "Dein {product_name}-Account wurde vorübergehend gesperrt",
@@ -17,67 +17,67 @@ const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Vielen Dank für deine {product_name}-Spende",
"body": "Hallo,\n\nvielen Dank für deine Spende an {product_name}! Deine {interval, select,\n month {wiederkehrende Spende}\n year {wiederkehrende Spende}\n other {einmalige Spende}\n} wurde erfolgreich {interval, select,\n month {eingerichtet}\n year {eingerichtet}\n other {verarbeitet}\n}.\n\nDetails zur Spende:\nBetrag: {amount} {currency} {interval, select,\n month {pro Monat}\n year {pro Jahr}\n other {}\n}\n\nStripe sendet dir in Kürze eine separate Quittung mit deiner Rechnungs-PDF per E-Mail. Diese enthält alle Zahlungsdetails und kann für Steuerzwecke verwendet werden.\n\nDu kannst deine Spendenhistorie einsehen, Rechnungen herunterladen {interval, select,\n month {und dein Abonnement jederzeit über diesen Link verwalten oder kündigen}\n year {und dein Abonnement jederzeit über diesen Link verwalten oder kündigen}\n other {und zukünftige Spenden verwalten}\n}:\n\n{manageUrl}\n\nDeine Unterstützung hilft, {product_name} am Laufen zu halten. Vielen Dank!\n\n– {product_name} Team"
"body": "Hallo,\n\nvielen Dank für deine Spende an {product_name}! Deine {interval, select,\n month {wiederkehrende Spende}\n year {wiederkehrende Spende}\n other {einmalige Spende}\n} wurde erfolgreich {interval, select,\n month {eingerichtet}\n year {eingerichtet}\n other {verarbeitet}\n}.\n\nDetails zur Spende:\nBetrag: {amount} {interval, select,\n month {pro Monat}\n year {pro Jahr}\n other {}\n}\n\nStripe sendet dir in Kürze eine separate Quittung mit deiner Rechnungs-PDF per E-Mail. Diese enthält alle Zahlungsdetails und kann für Steuerzwecke verwendet werden.\n\nÜber diesen Link kannst du jederzeit deine Spendenübersicht aufrufen, Rechnungen herunterladen {interval, select,\n month {und dein Abonnement verwalten oder kündigen}\n year {und dein Abonnement verwalten oder kündigen}\n other {und zukünftige Spenden verwalten}\n}:\n\n{manageUrl}\n\nDeine Unterstützung hilft, {product_name} am Laufen zu halten. Vielen Dank!\n\n– {product_name}-Team"
},
"donation_magic_link": {
"subject": "Verwalte deine {product_name}-Spenden",
"body": "Hallo,\n\nklicke auf den Link unten, um auf dein Spenderportal zuzugreifen:\n\n{manageUrl}\n\nIm Portal kannst du Abonnements verwalten, Rechnungen herunterladen und deine Spendenhistorie einsehen.\n\nDieser Link läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du diesen Link nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\n– {product_name} Team"
"body": "Hallo,\n\nklicke auf den Link unten, um auf dein Spendenportal zuzugreifen:\n\n{manageUrl}\n\nIm Portal kannst du Abonnements verwalten, Rechnungen herunterladen und deine Spendenübersicht einsehen.\n\nDieser Link läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du diesen Link nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\n– {product_name}-Team"
},
"dsa_report_verification": {
"subject": "Bestätige deine E-Mail für einen DSA-Bericht",
"body": "Hallo,\n\nverwende den untenstehenden Bestätigungscode, um deinen DSA-Bericht auf {product_name} einzureichen:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du dies nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\n– {product_name}-Sicherheitsteam"
"subject": "Bestätige deine E-Mail-Adresse für eine DSA-Meldung",
"body": "Hallo,\n\nverwende den untenstehenden Bestätigungscode, um deine Meldung nach dem Digital Services Act (DSA) auf {product_name} einzureichen:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du dies nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\n– {product_name}-Sicherheitsteam"
},
"email_change_new": {
"subject": "Bestätige deine neue {product_name}-E-Mail",
"body": "Hallo {username},\n\ngib diesen Code in der App ein, um deine neue {product_name}-E-Mail zu bestätigen:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du dies nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\n– {product_name} Team"
"subject": "Bestätige deine neue {product_name}-E-Mail-Adresse",
"body": "Hallo {username},\n\ngib diesen Code in der App ein, um deine neue {product_name}-E-Mail-Adresse zu bestätigen:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du dies nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\n– {product_name}-Team"
},
"email_change_original": {
"subject": "Bestätige deine {product_name}-E-Mail-Änderung",
"body": "Hallo {username},\n\nwir haben eine Anfrage zur Änderung der E-Mail-Adresse deines {product_name}-Accounts erhalten.\n\nUm diese Änderung zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du diese Änderung nicht angefordert hast, sichere deinen Account bitte sofort ab.\n\n– {product_name} Team"
"body": "Hallo {username},\n\nwir haben eine Anfrage zur Änderung der E-Mail-Adresse deines {product_name}-Accounts erhalten.\n\nUm diese Änderung zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du diese Änderung nicht angefordert hast, sichere deinen Account bitte sofort ab.\n\n– {product_name}-Team"
},
"email_change_revert": {
"subject": "Deine {product_name}-E-Mail wurde geändert",
"body": "Hallo {username},\n\ndie E-Mail-Adresse deines {product_name}-Accounts wurde zu {newEmail} geändert.\n\nWenn du diese Änderung vorgenommen hast, ist keine weitere Aktion erforderlich. Wenn nicht, kannst du die Änderung rückgängig machen und deinen Account über diesen Link absichern:\n\n{revertUrl}\n\nDadurch wird deine vorherige E-Mail wiederhergestellt, du wirst überall abgemeldet, verknüpfte Telefonnummern werden entfernt, MFA wird deaktiviert und du musst ein neues Passwort festlegen.\n\n– {product_name}-Sicherheitsteam"
"subject": "Deine {product_name}-E-Mail-Adresse wurde geändert",
"body": "Hallo {username},\n\ndie E-Mail-Adresse deines {product_name}-Accounts wurde in {newEmail} geändert.\n\nWenn du diese Änderung vorgenommen hast, ist keine weitere Aktion erforderlich. Wenn nicht, kannst du die Änderung rückgängig machen und deinen Account über diesen Link absichern:\n\n{revertUrl}\n\nDadurch wird deine vorherige E-Mail-Adresse wiederhergestellt, du wirst überall abgemeldet, verknüpfte Telefonnummern werden entfernt, MFA wird deaktiviert und du musst ein neues Passwort festlegen.\n\n– {product_name}-Sicherheitsteam"
},
"email_verification": {
"subject": "Bestätige deine {product_name}-E-Mail-Adresse",
"body": "Hallo {username},\n\nbitte bestätige die E-Mail-Adresse für deinen {product_name}-Account, indem du auf den untenstehenden Link klickst:\n\n{verifyUrl}\n\nWenn du keinen {product_name}-Account erstellt hast, kannst du diese E-Mail ignorieren.\n\nDieser Link ist 24 Stunden gültig.\n\n– {product_name} Team"
"body": "Hallo {username},\n\nbitte bestätige die E-Mail-Adresse für deinen {product_name}-Account, indem du auf den untenstehenden Link klickst:\n\n{verifyUrl}\n\nWenn du keinen {product_name}-Account erstellt hast, kannst du diese E-Mail ignorieren.\n\nDieser Link ist 24 Stunden gültig.\n\n– {product_name}-Team"
},
"gift_chargeback_notification": {
"subject": "Vorteile deines eingelösten Geschenks wurden entfernt",
"body": "Hallo {username},\n\nein von dir eingelöster Geschenkcode wurde ursprünglich von jemand anderem bezahlt. Diese Zahlung wurde inzwischen rückgängig gemacht (eine Rückbuchung).\n\nAus diesem Grund haben wir die Vorteile entfernt, die deinem Account hinzugefügt wurden, als du das Geschenk eingelöst hast.\n\nWenn du glaubst, dass dies ein Fehler ist, kontaktiere bitte unser Support-Team und gib alle Details an, die du über den Geschenkcode und den Zeitpunkt der Einlösung hast.\n\n– {product_name} Team"
"body": "Hallo {username},\n\nein von dir eingelöster Geschenkcode wurde ursprünglich von jemand anderem bezahlt. Diese Zahlung wurde inzwischen rückgängig gemacht (eine Rückbuchung).\n\nAus diesem Grund haben wir die Vorteile entfernt, die deinem Account hinzugefügt wurden, als du das Geschenk eingelöst hast.\n\nWenn du glaubst, dass dies ein Fehler ist, kontaktiere bitte unser Support-Team und gib alle Details an, die du über den Geschenkcode und den Zeitpunkt der Einlösung hast.\n\n– {product_name}-Team"
},
"harvest_completed": {
"subject": "Dein {product_name}-Datenexport steht zum Herunterladen bereit",
"body": "Hallo {username},\n\ndein Datenexport ist bereit.\n\nDownload-Link:\n{downloadUrl}\n\nEnthaltene Nachrichten: {totalMessages, number}\nDateigröße: {fileSizeMB, number} MB\n\nDieser Link läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du diesen Export nicht angefordert hast, ändere bitte sofort dein Passwort und kontaktiere unser Support-Team.\n\n– {product_name} Team"
"body": "Hallo {username},\n\ndein Datenexport ist bereit.\n\nDownload-Link:\n{downloadUrl}\n\nEnthaltene Nachrichten: {totalMessages, number}\nDateigröße: {fileSizeMB, number} MB\n\nDieser Link läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du diesen Export nicht angefordert hast, ändere bitte sofort dein Passwort und kontaktiere unser Support-Team.\n\n– {product_name}-Team"
},
"inactivity_warning": {
"subject": "Dein {product_name}-Account wird wegen Inaktivität gelöscht",
"body": "Hallo {username},\n\nwir haben seit dem {lastActiveDate, date, full} keine Aktivität auf deinem {product_name}-Account festgestellt.\n\nWenn du dich nicht bis zum {deletionDate, date, full} um {deletionDate, time, short} anmeldest, wird dein Account aufgrund von Inaktivität dauerhaft gelöscht.\n\nMelde dich hier an:\n{loginUrl}\n\nWenn du {product_name} kürzlich genutzt hast, kontaktiere bitte sofort unser Support-Team.\n\n– {product_name} Team"
"body": "Hallo {username},\n\nwir haben seit dem {lastActiveDate, date, full} keine Aktivität auf deinem {product_name}-Account festgestellt.\n\nWenn du dich nicht bis zum {deletionDate, date, full} um {deletionDate, time, short} anmeldest, wird dein Account aufgrund von Inaktivität dauerhaft gelöscht.\n\nMelde dich hier an:\n{loginUrl}\n\nWenn du {product_name} kürzlich genutzt hast, kontaktiere bitte sofort unser Support-Team.\n\n– {product_name}-Team"
},
"ip_authorization": {
"subject": "Anmeldung von einer neuen IP-Adresse bestätigen",
"body": "Hallo {username},\n\nwir haben einen Anmeldeversuch für deinen {product_name}-Account von einer neuen IP-Adresse festgestellt:\n\nIP-Adresse: {ipAddress}\nStandort: {location}\n\nWenn du das warst, bestätige diese IP-Adresse bitte, indem du auf den untenstehenden Link klickst:\n\n{authUrl}\n\nWenn du dich nicht anmelden wolltest, ändere bitte sofort dein Passwort.\n\nDieser Link ist 30 Minuten gültig.\n\n– {product_name} Team"
"body": "Hallo {username},\n\nwir haben einen Anmeldeversuch für deinen {product_name}-Account von einer neuen IP-Adresse festgestellt:\n\nIP-Adresse: {ipAddress}\nStandort: {location}\n\nWenn du das warst, bestätige diese IP-Adresse bitte, indem du auf den untenstehenden Link klickst:\n\n{authUrl}\n\nWenn du nicht versucht hast, dich anzumelden, ändere bitte sofort dein Passwort.\n\nDieser Link ist 30 Minuten gültig.\n\n– {product_name}-Team"
},
"mfa_backup_codes_view": {
"subject": "Bestätige den Zugriff auf deine {product_name}-Backup-Codes",
"body": "Hallo {username},\n\nwir haben eine Anfrage erhalten, die Backup-Codes deines {product_name}-Accounts anzusehen.\n\nUm diese Anfrage zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du dies nicht angefordert hast, könnte jemand Zugriff auf deinen Account haben. Ändere dein Passwort sofort.\n\n– {product_name} Team"
"body": "Hallo {username},\n\nwir haben eine Anfrage erhalten, die Backup-Codes deines {product_name}-Accounts anzusehen.\n\nUm diese Anfrage zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du dies nicht angefordert hast, könnte jemand Zugriff auf deinen Account haben. Ändere dein Passwort sofort.\n\n– {product_name}-Team"
},
"password_change_verification": {
"subject": "Bestätige deine {product_name}-Passwortänderung",
"body": "Hallo {username},\n\nwir haben eine Anfrage zur Änderung des Passworts deines {product_name}-Accounts erhalten.\n\nUm diese Änderung zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft um {expiresAt} ab.\n\nWenn du diese Änderung nicht angefordert hast, könnte jemand Zugriff auf deinen Account haben. Ändere dein Passwort sofort und aktiviere die Zwei-Faktor-Authentifizierung.\n\n– {product_name} Team"
"body": "Hallo {username},\n\nwir haben eine Anfrage zur Änderung des Passworts deines {product_name}-Accounts erhalten.\n\nUm diese Änderung zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft um {expiresAt} ab.\n\nWenn du diese Änderung nicht angefordert hast, könnte jemand Zugriff auf deinen Account haben. Ändere dein Passwort sofort und aktiviere die Zwei-Faktor-Authentifizierung.\n\n– {product_name}-Team"
},
"password_reset": {
"subject": "Setze dein {product_name}-Passwort zurück",
"body": "Hallo {username},\n\ndu hast eine {product_name}-Passwortzurücksetzung angefordert. Verwende den untenstehenden Link, um ein neues Passwort festzulegen:\n\n{resetUrl}\n\nWenn du dies nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\nDieser Link ist 1 Stunde gültig.\n\n– {product_name} Team"
"body": "Hallo {username},\n\ndu hast angefordert, dein {product_name}-Passwort zurückzusetzen. Verwende den untenstehenden Link, um ein neues Passwort festzulegen:\n\n{resetUrl}\n\nWenn du dies nicht angefordert hast, kannst du diese E-Mail ignorieren.\n\nDieser Link ist 1 Stunde gültig.\n\n– {product_name}-Team"
},
"registration_approved": {
"subject": "Deine {product_name}-Registrierung wurde genehmigt",
"body": "Hallo {username},\n\ngute Nachrichten: Deine {product_name}-Registrierung wurde genehmigt.\n\nDu kannst dich jetzt hier in die {product_name}-App einloggen:\n{channelsUrl}\n\nWillkommen in der {product_name}-Community.\n\n– {product_name} Team"
"body": "Hallo {username},\n\ngute Nachrichten: Deine {product_name}-Registrierung wurde genehmigt.\n\nDu kannst dich jetzt hier in die {product_name}-App einloggen:\n{channelsUrl}\n\nWillkommen in der {product_name}-Community.\n\n– {product_name}-Team"
},
"report_resolved": {
"subject": "Dein {product_name}-Bericht wurde geprüft",
"body": "Hallo {username},\n\ndein Bericht (ID: {reportId}) wurde von unserem Sicherheitsteam geprüft.{hasComment, select, yes {\n\nAntwort vom Sicherheitsteam:\n{publicComment}} other {}}\n\nDanke, dass du hilfst, {product_name} für alle sicher zu halten. Wir nehmen alle Berichte ernst und schätzen deinen Beitrag zur Community.\n\nWenn du Fragen oder Bedenken zu diesem Ergebnis hast, kontaktiere bitte {safety_email}.\n\n– {product_name}-Sicherheitsteam"
"subject": "Deine {product_name}-Meldung wurde geprüft",
"body": "Hallo {username},\n\ndeine Meldung (ID: {reportId}) wurde von unserem Sicherheitsteam geprüft.{hasComment, select, yes {\n\nAntwort vom Sicherheitsteam:\n{publicComment}} other {}}\n\nDanke, dass du hilfst, {product_name} für alle sicher zu halten. Wir nehmen alle Meldungen ernst und schätzen deinen Beitrag zur Community.\n\nWenn du Fragen oder Bedenken zu diesem Ergebnis hast, kontaktiere bitte {safety_email}.\n\n– {product_name}-Sicherheitsteam"
},
"scheduled_deletion_notification": {
"subject": "Dein {product_name}-Account wird dauerhaft gelöscht",
@@ -85,7 +85,7 @@ const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
},
"self_deletion_scheduled": {
"subject": "Die Löschung deines {product_name}-Accounts ist geplant",
"body": "Hallo {username},\n\ndu hast die Löschung deines {product_name}-Accounts beantragt. Dein Account ist zur dauerhaften Löschung vorgesehen am:\n\n{deletionDate, date, full} um {deletionDate, time, short}\n\nWenn du diese Löschung nicht beantragt hast, melde dich in deinem Account an, um die Löschung abzubrechen. Wir empfehlen außerdem, dein Passwort zu ändern, um deinen Account abzusichern.\n\n– {product_name} Team"
"body": "Hallo {username},\n\ndu hast die Löschung deines {product_name}-Accounts beantragt. Dein Account wird an folgendem Termin dauerhaft gelöscht:\n\n{deletionDate, date, full} um {deletionDate, time, short}\n\nWenn du diese Löschung nicht beantragt hast, melde dich in deinem Account an, um die Löschung abzubrechen. Wir empfehlen außerdem, dein Passwort zu ändern, um deinen Account abzusichern.\n\n– {product_name}-Team"
},
"unban_notification": {
"subject": "Die Sperrung deines {product_name}-Accounts wurde aufgehoben",
@@ -1,95 +1,95 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
const EMAIL_I18N_EL_MESSAGES = defineEmailI18nLocaleMessages({
"account_disabled_suspicious": {
"subject": "Ο λογαριασμός σου στο {product_name} έχει απενεργοποιηθεί προσωρινά",
"body": "Γεια σου {username},\n\nΑπενεργοποιήσαμε προσωρινά τον λογαριασμό σου στο {product_name} επειδή εντοπίσαμε ύποπτη δραστηριότητα.\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΓια να αποκτήσεις ξανά πρόσβαση στον λογαριασμό σου, θα πρέπει να επαναφέρεις τον κωδικό πρόσβασής σου:\n\n{forgotUrl}\n\nΑφού επαναφέρεις τον κωδικό πρόσβασής σου, θα μπορείς να συνδεθείς ξανά.\n\nΕάν πιστεύεις ότι αυτό έγινε κατά λάθος, επικοινώνησε με την ομάδα υποστήριξής μας.\n\n– Ομάδα Ασφαλείας {product_name}"
"body": "Γεια σου {username},\n\nΑπενεργοποιήσαμε προσωρινά τον λογαριασμό σου στο {product_name} επειδή εντοπίσαμε ύποπτη δραστηριότητα.\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΓια να αποκτήσεις ξανά πρόσβαση στον λογαριασμό σου, θα πρέπει να επαναφέρεις τον κωδικό πρόσβασής σου:\n\n{forgotUrl}\n\nΑφού επαναφέρεις τον κωδικό πρόσβασής σου, θα μπορείς να συνδεθείς ξανά.\n\nΕάν πιστεύεις ότι αυτό έγινε κατά λάθος, επικοινώνησε με την ομάδα υποστήριξής μας.\n\n– Η ομάδα ασφαλείας του {product_name}"
},
"account_scheduled_deletion": {
"subject": "Ο λογαριασμός σου στο {product_name} θα διαγραφεί οριστικά",
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει προγραμματιστεί για οριστική διαγραφή λόγω παραβιάσεων των Όρων Παροχής Υπηρεσιών ή των Οδηγιών Κοινότητας.\n\nΠρογραμματισμένη διαγραφή: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΠρόκειται για ένα σοβαρό μέτρο. Τα δεδομένα του λογαριασμού σου θα διαγραφούν οριστικά την προγραμματισμένη ημερομηνία.\n\nΈλεγξε:\n- Όροι Παροχής Υπηρεσιών: {termsUrl}\n- Οδηγίες Κοινότητας: {guidelinesUrl}\n\nΔιαδικασία προσφυγής:\nΕάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη ή αδικαιολόγητη, έχεις 60 ημέρες για να υποβάλεις προσφυγή. Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email.\n\nΣτην προσφυγή σου:\n- Εξήγησε σαφώς γιατί πιστεύεις ότι η απόφαση ήταν λανθασμένη ή αδικαιολόγητη\n- Παράθεσε τυχόν σχετικά αποδεικτικά στοιχεία ή πλαίσιο\n\nΈνα μέλος της Ομάδας Ασφαλείας του {product_name} θα εξετάσει την προσφυγή σου και ενδέχεται να αναστείλει την εκκρεμή διαγραφή μέχρι να ληφθεί μια τελική απόφαση.\n\n– Ομάδα Ασφαλείας {product_name}"
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει προγραμματιστεί για οριστική διαγραφή λόγω παραβιάσεων των Όρων παροχής υπηρεσιών ή των Οδηγιών κοινότητας.\n\nΠρογραμματισμένη διαγραφή: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΠρόκειται για σοβαρό μέτρο. Τα δεδομένα του λογαριασμού σου θα διαγραφούν οριστικά την προγραμματισμένη ημερομηνία.\n\nΈλεγξε:\n- Όροι παροχής υπηρεσιών: {termsUrl}\n- Οδηγίες κοινότητας: {guidelinesUrl}\n\nΔιαδικασία προσφυγής:\nΕάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη ή αδικαιολόγητη, έχεις 60 ημέρες για να υποβάλεις προσφυγή. Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email.\n\nΣτην προσφυγή σου:\n- Εξήγησε σαφώς γιατί πιστεύεις ότι η απόφαση ήταν λανθασμένη ή αδικαιολόγητη\n- Παράθεσε τυχόν σχετικά αποδεικτικά στοιχεία ή πλαίσιο\n\nΈνα μέλος της ομάδας ασφαλείας του {product_name} θα εξετάσει την προσφυγή σου και ενδέχεται να αναστείλει την εκκρεμή διαγραφή μέχρι να ληφθεί τελική απόφαση.\n\n– Η ομάδα ασφαλείας του {product_name}"
},
"account_temp_banned": {
"subject": "Ο λογαριασμός σου στο {product_name} έχει ανασταλεί προσωρινά",
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει ανασταλεί προσωρινά για παραβίαση των Όρων Παροχής Υπηρεσιών ή των Οδηγιών Κοινότητας.\n\nΔιάρκεια: {durationHours, plural,\n =1 {μία ώρα}\n other {# ώρες}\n}\nΑναστολή μέχρι: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΚατά τη διάρκεια αυτής της περιόδου, δεν θα μπορείς να έχεις πρόσβαση στον λογαριασμό σου.\n\nΈλεγξε:\n- Όροι Παροχής Υπηρεσιών: {termsUrl}\n- Οδηγίες Κοινότητας: {guidelinesUrl}\n\nΕάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη ή αδικαιολόγητη, μπορείς να υποβάλεις προσφυγή. Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email και εξήγησε σαφώς γιατί πιστεύεις ότι η απόφαση ήταν λανθασμένη. Θα εξετάσουμε την προσφυγή σου και θα απαντήσουμε με την απόφασή μας.\n\n– Ομάδα Ασφαλείας {product_name}"
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει ανασταλεί προσωρινά για παραβίαση των Όρων παροχής υπηρεσιών ή των Οδηγιών κοινότητας.\n\nΔιάρκεια: {durationHours, plural,\n =1 {μία ώρα}\n other {# ώρες}\n}\nΑναστολή μέχρι: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΚατά τη διάρκεια αυτής της περιόδου, δεν θα έχεις πρόσβαση στον λογαριασμό σου.\n\nΈλεγξε:\n- Όροι παροχής υπηρεσιών: {termsUrl}\n- Οδηγίες κοινότητας: {guidelinesUrl}\n\nΕάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη ή αδικαιολόγητη, μπορείς να υποβάλεις προσφυγή. Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email και εξήγησε σαφώς γιατί πιστεύεις ότι η απόφαση ήταν λανθασμένη. Θα εξετάσουμε την προσφυγή σου και θα σου απαντήσουμε με την απόφασή μας.\n\n– Η ομάδα ασφαλείας του {product_name}"
},
"donation_confirmation": {
"subject": "Ευχαριστούμε για τη δωρεά σου στο {product_name}",
"body": "Γεια σου,\n\nΕυχαριστούμε για τη δωρεά σου στο {product_name}! Η {interval, select,\n month {επαναλαμβανόμενη δωρεά}\n year {επαναλαμβανόμενη δωρεά}\n other {εφάπαξ δωρεά}\n} σου έχει {interval, select,\n month {ρυθμιστεί}\n year {ρυθμιστεί}\n other {επεξεργαστεί}\n} επιτυχώς.\n\nΛεπτομέρειες δωρεάς:\nΠοσό: {amount} {currency} {interval, select,\n month {ανά μήνα}\n year {ανά έτος}\n other {}\n}\n\nΗ Stripe θα σου στείλει σύντομα μια ξεχωριστή απόδειξη με το PDF του τιμολογίου σου. Αυτό περιλαμβάνει όλες τις λεπτομέρειες πληρωμής και μπορεί να χρησιμοποιηθεί για φορολογικούς σκοπούς.\n\nΜπορείς να δεις το ιστορικό των δωρεών σου, να κατεβάσεις τιμολόγια, {interval, select,\n month {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n year {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n other {και να διαχειριστείς μελλοντικές δωρεές}\n} ανά πάσα στιγμή χρησιμοποιώντας αυτόν τον σύνδεσμο:\n\n{manageUrl}\n\nΗ υποστήριξή σου βοηθάει το {product_name} να συνεχίσει να λειτουργεί. Ευχαριστούμε!\n\n– Ομάδα {product_name}"
"body": "Γεια σου,\n\nΕυχαριστούμε για τη δωρεά σου στο {product_name}! Η {interval, select,\n month {επαναλαμβανόμενη δωρεά}\n year {επαναλαμβανόμενη δωρεά}\n other {εφάπαξ δωρεά}\n} σου έχει {interval, select,\n month {ρυθμιστεί}\n year {ρυθμιστεί}\n other {ολοκληρωθεί}\n} με επιτυχία.\n\nΛεπτομέρειες δωρεάς:\nΠοσό: {amount} {interval, select,\n month {ανά μήνα}\n year {ανά έτος}\n other {}\n}\n\nΗ Stripe θα σου στείλει σύντομα ξεχωριστή απόδειξη με το PDF του τιμολογίου σου. Περιλαμβάνει όλες τις λεπτομέρειες πληρωμής και μπορεί να χρησιμοποιηθεί για φορολογικούς σκοπούς.\n\nΜπορείς να δεις το ιστορικό των δωρεών σου, να κατεβάσεις τιμολόγια, {interval, select,\n month {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n year {και να διαχειριστείς ή να ακυρώσεις τη συνδρομή σου}\n other {και να διαχειριστείς μελλοντικές δωρεές}\n} ανά πάσα στιγμή χρησιμοποιώντας αυτόν τον σύνδεσμο:\n\n{manageUrl}\n\nΗ υποστήριξή σου βοηθάει το {product_name} να συνεχίσει να λειτουργεί. Ευχαριστούμε!\n\n– Η ομάδα του {product_name}"
},
"donation_magic_link": {
"subject": "Διαχείριση των δωρεών σου στο {product_name}",
"body": "Γεια σου,\n\nΚάνε κλικ στον παρακάτω σύνδεσμο για να αποκτήσεις πρόσβαση στην πύλη δωρητών σου:\n\n{manageUrl}\n\nΣτην πύλη, μπορείς να διαχειριστείς τις συνδρομές, να κατεβάσεις τιμολόγια και να δεις το ιστορικό των δωρεών σου.\n\nΑυτός ο σύνδεσμος λήγει στις {expiresAt, date, full} στις {expiresAt, time, short}.\n\nΕάν δεν ζήτησες αυτόν τον σύνδεσμο, μπορείς να αγνοήσεις με ασφάλεια αυτό το email.\n\n– Ομάδα {product_name}"
"subject": "Διαχειρίσου τις δωρεές σου στο {product_name}",
"body": "Γεια σου,\n\nΚάνε κλικ στον παρακάτω σύνδεσμο για να αποκτήσεις πρόσβαση στην πύλη δωρητών σου:\n\n{manageUrl}\n\nΣτην πύλη μπορείς να διαχειριστείς τις συνδρομές, να κατεβάσεις τιμολόγια και να δεις το ιστορικό των δωρεών σου.\n\nΑυτός ο σύνδεσμος λήγει στις {expiresAt, date, full} και ώρα {expiresAt, time, short}.\n\nΕάν δεν ζήτησες αυτόν τον σύνδεσμο, μπορείς να αγνοήσεις με ασφάλεια αυτό το email.\n\n– Η ομάδα του {product_name}"
},
"dsa_report_verification": {
"subject": "Επαλήθευσε το email σου για μια αναφορά DSA",
"body": "Γεια σου,\n\nΧρησιμοποίησε τον παρακάτω κωδικό επαλήθευσης για να υποβάλεις την αναφορά σου για τον Νόμο περί Ψηφιακών Υπηρεσιών στο {product_name}:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} στις {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, μπορείς να αγνοήσεις αυτό το email.\n\n– Ομάδα Ασφαλείας {product_name}"
"subject": "Επαλήθευσε το email σου για αναφορά DSA",
"body": "Γεια σου,\n\nΧρησιμοποίησε τον παρακάτω κωδικό επαλήθευσης για να υποβάλεις την αναφορά σου βάσει της Πράξης για τις ψηφιακές υπηρεσίες στο {product_name}:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} και ώρα {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, μπορείς να αγνοήσεις αυτό το email.\n\n– Η ομάδα ασφαλείας του {product_name}"
},
"email_change_new": {
"subject": "Επαλήθευσε το νέο σου email στο {product_name}",
"body": "Γεια σου {username},\n\nΕισήγαγε αυτόν τον κωδικό στην εφαρμογή για να επαληθεύσεις το νέο σου email στο {product_name}:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} στις {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, μπορείς να αγνοήσεις αυτό το email.\n\n– Ομάδα {product_name}"
"body": "Γεια σου {username},\n\nΠληκτρολόγησε αυτόν τον κωδικό στην εφαρμογή για να επαληθεύσεις το νέο σου email στο {product_name}:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} και ώρα {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, μπορείς να αγνοήσεις αυτό το email.\n\n– Η ομάδα του {product_name}"
},
"email_change_original": {
"subject": "Επιβεβαίωσε την αλλαγή email στο {product_name}",
"body": "Γεια σου {username},\n\nΛάβαμε ένα αίτημα για αλλαγή της διεύθυνσης email στον λογαριασμό σου στο {product_name}.\n\nΓια να επιβεβαιώσεις αυτήν την αλλαγή, εισήγαγε αυτόν τον κωδικό στην εφαρμογή:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} στις {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, ασφάλισε αμέσως τον λογαριασμό σου.\n\n– Ομάδα {product_name}"
"body": "Γεια σου {username},\n\nΛάβαμε αίτημα για αλλαγή της διεύθυνσης email στον λογαριασμό σου στο {product_name}.\n\nΓια να επιβεβαιώσεις αυτήν την αλλαγή, πληκτρολόγησε αυτόν τον κωδικό στην εφαρμογή:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} και ώρα {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, ασφάλισε αμέσως τον λογαριασμό σου.\n\n– Η ομάδα του {product_name}"
},
"email_change_revert": {
"subject": "Το email στο {product_name} άλλαξε",
"body": "Γεια σου {username},\n\nΗ διεύθυνση email του λογαριασμού σου στο {product_name} άλλαξε σε {newEmail}.\n\nΕάν έκανες αυτήν την αλλαγή, δεν απαιτείται καμία ενέργεια. Εάν όχι, μπορείς να αναιρέσεις την αλλαγή και να ασφαλίσεις τον λογαριασμό σου χρησιμοποιώντας αυτόν τον σύνδεσμο:\n\n{revertUrl}\n\nΑυτό θα επαναφέρει το προηγούμενο email σου, θα σε αποσυνδέσει από παντού, θα αφαιρέσει τους συνδεδεμένους αριθμούς τηλεφώνου, θα απενεργοποιήσει το MFA και θα απαιτήσει να ορίσεις έναν νέο κωδικό πρόσβασης.\n\n– Ομάδα Ασφαλείας {product_name}"
"subject": "Το email σου στο {product_name} άλλαξε",
"body": "Γεια σου {username},\n\nΗ διεύθυνση email του λογαριασμού σου στο {product_name} άλλαξε σε {newEmail}.\n\nΕάν έκανες εσύ αυτήν την αλλαγή, δεν χρειάζεται να κάνεις τίποτα. Εάν όχι, μπορείς να αναιρέσεις την αλλαγή και να ασφαλίσεις τον λογαριασμό σου με αυτόν τον σύνδεσμο:\n\n{revertUrl}\n\nΑυτό θα επαναφέρει το προηγούμενο email σου, θα σε αποσυνδέσει από παντού, θα αφαιρέσει τους συνδεδεμένους αριθμούς τηλεφώνου, θα απενεργοποιήσει το MFA και θα σου ζητήσει να ορίσεις νέο κωδικό πρόσβασης.\n\n– Η ομάδα ασφαλείας του {product_name}"
},
"email_verification": {
"subject": "Επαλήθευσε τη διεύθυνση email σου στο {product_name}",
"body": "Γεια σου {username},\n\nΕπαλήθευσε τη διεύθυνση email για τον λογαριασμό σου στο {product_name} κάνοντας κλικ στον παρακάτω σύνδεσμο:\n\n{verifyUrl}\n\nΕάν δεν δημιούργησες λογαριασμό στο {product_name}, μπορείς να αγνοήσεις με ασφάλεια αυτό το email.\n\nΑυτός ο σύνδεσμος ισχύει για 24 ώρες.\n\n– Ομάδα {product_name}"
"body": "Γεια σου {username},\n\nΕπαλήθευσε τη διεύθυνση email για τον λογαριασμό σου στο {product_name} κάνοντας κλικ στον παρακάτω σύνδεσμο:\n\n{verifyUrl}\n\nΕάν δεν δημιούργησες λογαριασμό στο {product_name}, μπορείς να αγνοήσεις με ασφάλεια αυτό το email.\n\nΑυτός ο σύνδεσμος ισχύει για 24 ώρες.\n\n– Η ομάδα του {product_name}"
},
"gift_chargeback_notification": {
"subject": "Τα προνόμια από το δώρο που εξαργύρωσες έχουν αφαιρεθεί",
"body": "Γεια σου {username},\n\nΈνας κωδικός δώρου που εξαργύρωσες πληρώθηκε αρχικά από κάποιον άλλο. Αυτή η πληρωμή έχει έκτοτε αντιστραφεί (αμφισβήτηση χρέωσης).\n\nΛόγω αυτού, αφαιρέσαμε τα προνόμια που προστέθηκαν στον λογαριασμό σου όταν εξαργύρωσες το δώρο.\n\nΕάν πιστεύεις ότι πρόκειται για λάθος, επικοινώνησε με την ομάδα υποστήριξής μας και συμπερίλαβε τυχόν λεπτομέρειες που έχεις σχετικά με τον κωδικό δώρου και πότε τον εξαργύρωσες.\n\n– Ομάδα {product_name}"
"body": "Γεια σου {username},\n\nΈνας κωδικός δώρου που εξαργύρωσες πληρώθηκε αρχικά από κάποιον άλλο. Αυτή η πληρωμή έχει έκτοτε αντιστραφεί (αμφισβήτηση χρέωσης).\n\nΓι' αυτόν τον λόγο, αφαιρέσαμε τα προνόμια που προστέθηκαν στον λογαριασμό σου όταν εξαργύρωσες το δώρο.\n\nΕάν πιστεύεις ότι πρόκειται για λάθος, επικοινώνησε με την ομάδα υποστήριξής μας και ανάφερε όσες λεπτομέρειες έχεις σχετικά με τον κωδικό δώρου και το πότε τον εξαργύρωσες.\n\n– Η ομάδα του {product_name}"
},
"harvest_completed": {
"subject": "Η εξαγωγή δεδομένων σου στο {product_name} είναι έτοιμη για λήψη",
"body": "Γεια σου {username},\n\nΗ εξαγωγή δεδομένων σου είναι έτοιμη.\n\nΣύνδεσμος λήψης:\n{downloadUrl}\n\nΣυμπεριλαμβανόμενα μηνύματα: {totalMessages, number}\nΜέγεθος αρχείου: {fileSizeMB, number} MB\n\nΑυτός ο σύνδεσμος λήγει στις {expiresAt, date, full} στις {expiresAt, time, short}.\n\nΕάν δεν ζήτησες αυτήν την εξαγωγή, άλλαξε αμέσως τον κωδικό πρόσβασής σου και επικοινώνησε με την ομάδα υποστήριξής μας.\n\n– Ομάδα {product_name}"
"body": "Γεια σου {username},\n\nΗ εξαγωγή δεδομένων σου είναι έτοιμη.\n\nΣύνδεσμος λήψης:\n{downloadUrl}\n\nΜηνύματα που περιλαμβάνονται: {totalMessages, number}\nΜέγεθος αρχείου: {fileSizeMB, number} MB\n\nΑυτός ο σύνδεσμος λήγει στις {expiresAt, date, full} και ώρα {expiresAt, time, short}.\n\nΕάν δεν ζήτησες αυτήν την εξαγωγή, άλλαξε αμέσως τον κωδικό πρόσβασής σου και επικοινώνησε με την ομάδα υποστήριξής μας.\n\n– Η ομάδα του {product_name}"
},
"inactivity_warning": {
"subject": "Ο λογαριασμός σου στο {product_name} θα διαγραφεί λόγω αδράνειας",
"body": "Γεια σου {username},\n\nΔεν έχουμε δει καμία δραστηριότητα στον λογαριασμό σου στο {product_name} από τις {lastActiveDate, date, full}.\n\nΕάν δεν συνδεθείς μέχρι τις {deletionDate, date, full} στις {deletionDate, time, short}, ο λογαριασμός σου θα διαγραφεί οριστικά λόγω αδράνειας.\n\nΣυνδέσου εδώ:\n{loginUrl}\n\nΕάν χρησιμοποίησες το {product_name} πρόσφατα, επικοινώνησε αμέσως με την ομάδα υποστήριξής μας.\n\n– Ομάδα {product_name}"
"body": "Γεια σου {username},\n\nΔεν έχουμε δει καμία δραστηριότητα στον λογαριασμό σου στο {product_name} από τις {lastActiveDate, date, full}.\n\nΕάν δεν συνδεθείς μέχρι τις {deletionDate, date, full} και ώρα {deletionDate, time, short}, ο λογαριασμός σου θα διαγραφεί οριστικά λόγω αδράνειας.\n\nΣυνδέσου εδώ:\n{loginUrl}\n\nΕάν χρησιμοποίησες το {product_name} πρόσφατα, επικοινώνησε αμέσως με την ομάδα υποστήριξής μας.\n\n– Η ομάδα του {product_name}"
},
"ip_authorization": {
"subject": "Εξουσιοδότησε τη σύνδεση από μια νέα διεύθυνση IP",
"body": "Γεια σου {username},\n\nΕντοπίσαμε μια προσπάθεια σύνδεσης στον λογαριασμό σου στο {product_name} από μια νέα διεύθυνση IP:\n\nΔιεύθυνση IP: {ipAddress}\nΤοποθεσία: {location}\n\nΕάν ήσουν εσύ, εξουσιοδότησε αυτήν τη διεύθυνση IP κάνοντας κλικ στον παρακάτω σύνδεσμο:\n\n{authUrl}\n\nΕάν δεν προσπάθησες να συνδεθείς, άλλαξε αμέσως τον κωδικό πρόσβασής σου.\n\nΑυτός ο σύνδεσμος ισχύει για 30 λεπτά.\n\n– Ομάδα {product_name}"
"body": "Γεια σου {username},\n\nΕντοπίσαμε μια προσπάθεια σύνδεσης στον λογαριασμό σου στο {product_name} από νέα διεύθυνση IP:\n\nΔιεύθυνση IP: {ipAddress}\nΤοποθεσία: {location}\n\nΕάν ήσουν εσύ, εξουσιοδότησε αυτήν τη διεύθυνση IP κάνοντας κλικ στον παρακάτω σύνδεσμο:\n\n{authUrl}\n\nΕάν δεν προσπάθησες να συνδεθείς, άλλαξε αμέσως τον κωδικό πρόσβασής σου.\n\nΑυτός ο σύνδεσμος ισχύει για 30 λεπτά.\n\n– Η ομάδα του {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Επιβεβαίωσε την πρόσβαση στους εφεδρικούς κωδικούς σου στο {product_name}",
"body": "Γεια σου {username},\n\nΛάβαμε ένα αίτημα για προβολή των εφεδρικών κωδικών στον λογαριασμό σου στο {product_name}.\n\nΓια να επιβεβαιώσεις αυτό το αίτημα, εισήγαγε αυτόν τον κωδικό στην εφαρμογή:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} στις {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, κάποιος μπορεί να έχει πρόσβαση στον λογαριασμό σου. Άλλαξε αμέσως τον κωδικό πρόσβασής σου.\n\n– Ομάδα {product_name}"
"body": "Γεια σου {username},\n\nΛάβαμε αίτημα για προβολή των εφεδρικών κωδικών στον λογαριασμό σου στο {product_name}.\n\nΓια να επιβεβαιώσεις αυτό το αίτημα, πληκτρολόγησε αυτόν τον κωδικό στην εφαρμογή:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} και ώρα {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, μπορεί κάποιος να έχει πρόσβαση στον λογαριασμό σου. Άλλαξε αμέσως τον κωδικό πρόσβασής σου.\n\n– Η ομάδα του {product_name}"
},
"password_change_verification": {
"subject": "Επιβεβαίωσε την αλλαγή κωδικού πρόσβασης στο {product_name}",
"body": "Γεια σου {username},\n\nΛάβαμε ένα αίτημα για αλλαγή του κωδικού πρόσβασης στον λογαριασμό σου στο {product_name}.\n\nΓια να επιβεβαιώσεις αυτήν την αλλαγή, εισήγαγε αυτόν τον κωδικό στην εφαρμογή:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt}.\n\nΕάν δεν το ζήτησες, κάποιος μπορεί να έχει πρόσβαση στον λογαριασμό σου. Άλλαξε αμέσως τον κωδικό πρόσβασής σου και ενεργοποίησε τον έλεγχο ταυτότητας δύο παραγόντων.\n\n– Ομάδα {product_name}"
"body": "Γεια σου {username},\n\nΛάβαμε αίτημα για αλλαγή του κωδικού πρόσβασης στον λογαριασμό σου στο {product_name}.\n\nΓια να επιβεβαιώσεις αυτήν την αλλαγή, πληκτρολόγησε αυτόν τον κωδικό στην εφαρμογή:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt}.\n\nΕάν δεν το ζήτησες, μπορεί κάποιος να έχει πρόσβαση στον λογαριασμό σου. Άλλαξε αμέσως τον κωδικό πρόσβασής σου και ενεργοποίησε τον έλεγχο ταυτότητας δύο παραγόντων.\n\n– Η ομάδα του {product_name}"
},
"password_reset": {
"subject": "Επαναφορά του κωδικού πρόσβασής σου στο {product_name}",
"body": "Γεια σου {username},\n\nΖήτησες επαναφορά κωδικού πρόσβασης του {product_name}. Χρησιμοποίησε τον παρακάτω σύνδεσμο για να ορίσεις έναν νέο κωδικό πρόσβασης:\n\n{resetUrl}\n\nΕάν δεν το ζήτησες, μπορείς να αγνοήσεις με ασφάλεια αυτό το email.\n\nΑυτός ο σύνδεσμος ισχύει για 1 ώρα.\n\n– Ομάδα {product_name}"
"subject": "Επανάφερε τον κωδικό πρόσβασής σου στο {product_name}",
"body": "Γεια σου {username},\n\nΖήτησες επαναφορά του κωδικού πρόσβασής σου στο {product_name}. Χρησιμοποίησε τον παρακάτω σύνδεσμο για να ορίσεις νέο κωδικό πρόσβασης:\n\n{resetUrl}\n\nΕάν δεν το ζήτησες, μπορείς να αγνοήσεις με ασφάλεια αυτό το email.\n\nΑυτός ο σύνδεσμος ισχύει για 1 ώρα.\n\n– Η ομάδα του {product_name}"
},
"registration_approved": {
"subject": "Η εγγραφή σου στο {product_name} έχει εγκριθεί",
"body": "Γεια σου {username},\n\nΚαλά νέα: η εγγραφή σου στο {product_name} έχει εγκριθεί.\n\nΜπορείς τώρα να συνδεθείς στην εφαρμογή του {product_name} εδώ:\n{channelsUrl}\n\nΚαλώς ήρθες στην κοινότητα του {product_name}.\n\n– Ομάδα {product_name}"
"body": "Γεια σου {username},\n\nΚαλά νέα: η εγγραφή σου στο {product_name} έχει εγκριθεί.\n\nΜπορείς τώρα να συνδεθείς στην εφαρμογή του {product_name} εδώ:\n{channelsUrl}\n\nΚαλώς ήρθες στην κοινότητα του {product_name}.\n\n– Η ομάδα του {product_name}"
},
"report_resolved": {
"subject": "Η αναφορά σου στο {product_name} έχει εξεταστεί",
"body": "Γεια σου {username},\n\nΗ αναφορά σου (ID: {reportId}) έχει εξεταστεί από την Ομάδα Ασφαλείας μας.{hasComment, select, yes {\n\nΑπάντηση από την Ομάδα Ασφαλείας:\n{publicComment}} other {}}\n\nΕυχαριστούμε που βοηθάς να διατηρήσουμε το {product_name} ασφαλές για όλους. Λαμβάνουμε όλες τις αναφορές σοβαρά υπόψη και εκτιμούμε τη συμβολή σου στην κοινότητα.\n\nΕάν έχεις οποιεσδήποτε ερωτήσεις ή ανησυχίες σχετικά με αυτό το αποτέλεσμα, επικοινώνησε με το {safety_email}.\n\n– Ομάδα Ασφαλείας {product_name}"
"body": "Γεια σου {username},\n\nΗ αναφορά σου (ID: {reportId}) εξετάστηκε από την ομάδα ασφαλείας μας.{hasComment, select, yes {\n\nΑπάντηση από την ομάδα ασφαλείας:\n{publicComment}} other {}}\n\nΕυχαριστούμε που βοηθάς να παραμείνει το {product_name} ασφαλές για όλους. Λαμβάνουμε σοβαρά υπόψη κάθε αναφορά και εκτιμούμε τη συμβολή σου στην κοινότητα.\n\nΕάν έχεις ερωτήσεις ή απορίες σχετικά με αυτό το αποτέλεσμα, στείλε email στο {safety_email}.\n\n– Η ομάδα ασφαλείας του {product_name}"
},
"scheduled_deletion_notification": {
"subject": "Ο λογαριασμός σου στο {product_name} θα διαγραφεί οριστικά",
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει προγραμματιστεί για οριστική διαγραφή.\n\nΠρογραμματισμένη διαγραφή: {deletionDate, date, full} στις {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΠρόκειται για ένα σοβαρό μέτρο. Τα δεδομένα του λογαριασμού σου θα διαγραφούν οριστικά την προγραμματισμένη ημερομηνία.\n\nΕάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη, μπορείς να υποβάλεις προσφυγή. Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email.\n\n– Ομάδα Ασφαλείας {product_name}"
"body": "Γεια σου {username},\n\nΟ λογαριασμός σου στο {product_name} έχει προγραμματιστεί για οριστική διαγραφή.\n\nΠρογραμματισμένη διαγραφή: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΠρόκειται για σοβαρό μέτρο. Τα δεδομένα του λογαριασμού σου θα διαγραφούν οριστικά την προγραμματισμένη ημερομηνία.\n\nΕάν πιστεύεις ότι αυτή η απόφαση ήταν λανθασμένη, μπορείς να υποβάλεις προσφυγή. Στείλε email στο {appeals_email} από αυτήν τη διεύθυνση email.\n\n– Η ομάδα ασφαλείας του {product_name}"
},
"self_deletion_scheduled": {
"subject": "Η διαγραφή του λογαριασμού σου στο {product_name} είναι προγραμματισμένη",
"body": "Γεια σου {username},\n\nΖήτησες τη διαγραφή του λογαριασμού σου στο {product_name}. Ο λογαριασμός σου έχει προγραμματιστεί για οριστική διαγραφή στις:\n\n{deletionDate, date, full} στις {deletionDate, time, short}\n\nΕάν δεν το ζήτησες, συνδέσου στον λογαριασμό σου για να ακυρώσεις τη διαγραφή. Σου συνιστούμε επίσης να αλλάξεις τον κωδικό πρόσβασής σου και να ασφαλίσεις τον λογαριασμό σου.\n\n– Ομάδα {product_name}"
"body": "Γεια σου {username},\n\nΖήτησες τη διαγραφή του λογαριασμού σου στο {product_name}. Ο λογαριασμός σου έχει προγραμματιστεί για οριστική διαγραφή:\n\n{deletionDate, date, full} και ώρα {deletionDate, time, short}\n\nΕάν δεν το ζήτησες, συνδέσου στον λογαριασμό σου για να ακυρώσεις τη διαγραφή. Σου προτείνουμε επίσης να αλλάξεις τον κωδικό πρόσβασής σου για να ασφαλίσεις τον λογαριασμό σου.\n\n– Η ομάδα του {product_name}"
},
"unban_notification": {
"subject": "Η αναστολή του λογαριασμού σου στο {product_name} έχει αρθεί",
"body": "Γεια σου {username},\n\nΚαλά νέα: η αναστολή του λογαριασμού σου στο {product_name} έχει αρθεί.\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΜπορείς τώρα να συνδεθείς ξανά και να συνεχίσεις να χρησιμοποιείς το {product_name} κανονικά.\n\n– Ομάδα Ασφαλείας {product_name}"
"body": "Γεια σου {username},\n\nΚαλά νέα: η αναστολή του λογαριασμού σου στο {product_name} έχει αρθεί.\n\n{reason, select,\n null {}\n other {Λόγος: {reason}}\n}\n\nΜπορείς τώρα να συνδεθείς ξανά και να συνεχίσεις να χρησιμοποιείς το {product_name} κανονικά.\n\n– Η ομάδα ασφαλείας του {product_name}"
}
});
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
const EMAIL_I18N_EN_GB_MESSAGES = defineEmailI18nLocaleMessages({
"account_disabled_suspicious": {
@@ -17,7 +17,7 @@ const EMAIL_I18N_EN_GB_MESSAGES = defineEmailI18nLocaleMessages({
},
"donation_confirmation": {
"subject": "Thank you for your {product_name} donation",
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {currency} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
"body": "Hello,\n\nThank you for your donation to {product_name}! Your {interval, select,\n month {recurring donation}\n year {recurring donation}\n other {one-time donation}\n} has been {interval, select,\n month {set up}\n year {set up}\n other {processed}\n} successfully.\n\nDonation details:\nAmount: {amount} {interval, select,\n month {per month}\n year {per year}\n other {}\n}\n\nStripe will email you a separate receipt with your invoice PDF shortly. This includes all payment details and can be used for tax purposes.\n\nYou can view your donation history, download invoices, {interval, select,\n month {and manage or cancel your subscription}\n year {and manage or cancel your subscription}\n other {and manage future donations}\n} at any time using this link:\n\n{manageUrl}\n\nYour support helps keep {product_name} running. Thank you!\n\n– {product_name} Team"
},
"donation_magic_link": {
"subject": "Manage your {product_name} donations",
@@ -56,8 +56,8 @@ const EMAIL_I18N_EN_GB_MESSAGES = defineEmailI18nLocaleMessages({
"body": "Hello {username},\n\nWe haven't seen any activity on your {product_name} account since {lastActiveDate, date, full}.\n\nIf you don't log in by {deletionDate, date, full} at {deletionDate, time, short}, your account will be permanently deleted due to inactivity.\n\nLog in here:\n{loginUrl}\n\nIf you've used {product_name} recently, please contact our support team right away.\n\n– {product_name} Team"
},
"ip_authorization": {
"subject": "Authorize login from a new IP address",
"body": "Hello {username},\n\nWe detected a login attempt to your {product_name} account from a new IP address:\n\nIP address: {ipAddress}\nLocation: {location}\n\nIf this was you, please authorize this IP address by clicking the link below:\n\n{authUrl}\n\nIf you didn't attempt to log in, please change your password right away.\n\nThis link is valid for 30 minutes.\n\n– {product_name} Team"
"subject": "Authorise login from a new IP address",
"body": "Hello {username},\n\nWe detected a login attempt to your {product_name} account from a new IP address:\n\nIP address: {ipAddress}\nLocation: {location}\n\nIf this was you, please authorise this IP address by clicking the link below:\n\n{authUrl}\n\nIf you didn't attempt to log in, please change your password right away.\n\nThis link is valid for 30 minutes.\n\n– {product_name} Team"
},
"mfa_backup_codes_view": {
"subject": "Confirm access to your {product_name} backup codes",
@@ -65,7 +65,7 @@ const EMAIL_I18N_EN_GB_MESSAGES = defineEmailI18nLocaleMessages({
},
"password_change_verification": {
"subject": "Confirm your {product_name} password change",
"body": "Hello {username},\n\nWe received a request to change the password on your {product_name} account.\n\nTo confirm this change, enter this code in the app:\n\n{code}\n\nThis code expires at {expiresAt}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately and enable two-factor authentication.\n\n– {product_name} Team"
"body": "Hello {username},\n\nWe received a request to change the password on your {product_name} account.\n\nTo confirm this change, enter this code in the app:\n\n{code}\n\nThis code expires on {expiresAt, date, full} at {expiresAt, time, short}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately and enable two-factor authentication.\n\n– {product_name} Team"
},
"password_reset": {
"subject": "Reset your {product_name} password",
@@ -1,46 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
const EMAIL_I18N_ES_419_MESSAGES = defineEmailI18nLocaleMessages({
"account_disabled_suspicious": {
"subject": "Tu cuenta de {product_name} ha sido deshabilitada temporalmente",
"body": "Hola {username}:\n\nDeshabilitamos temporalmente tu cuenta de {product_name} porque detectamos actividad sospechosa.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nPara recuperar el acceso a tu cuenta, deberás restablecer tu contraseña:\n\n{forgotUrl}\n\nDespués de restablecer tu contraseña, podrás iniciar sesión de nuevo.\n\nSi crees que esto fue un error, comunícate con nuestro equipo de soporte.\n\n– Equipo de Seguridad de {product_name}"
"body": "Hola {username}:\n\nDeshabilitamos temporalmente tu cuenta de {product_name} porque detectamos actividad sospechosa.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nPara recuperar el acceso a tu cuenta, deberás restablecer tu contraseña:\n\n{forgotUrl}\n\nDespués de restablecer tu contraseña, podrás iniciar sesión de nuevo.\n\nSi crees que esto fue un error, comunícate con nuestro equipo de soporte.\n\n– Equipo de seguridad de {product_name}"
},
"account_scheduled_deletion": {
"subject": "Tu cuenta de {product_name} será eliminada permanentemente",
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido programada para su eliminación permanente debido a infracciones de nuestros Términos de Servicio o Normas de la Comunidad.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nPor favor, revisa:\n- Términos de Servicio: {termsUrl}\n- Normas de la Comunidad: {guidelinesUrl}\n\nProceso de apelación:\nSi crees que esta decisión fue incorrecta o injustificada, tienes 60 días para presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\nEn tu apelación:\n- Explica claramente por qué crees que la decisión fue incorrecta o injustificada\n- Proporciona cualquier evidencia o contexto relevante\n\nUn miembro del Equipo de Seguridad de {product_name} revisará tu apelación y podrá pausar la eliminación pendiente hasta que se haya tomado una decisión final.\n\n– Equipo de Seguridad de {product_name}"
"body": "Hola {username}:\n\nSe ha programado la eliminación permanente de tu cuenta de {product_name} debido a infracciones de nuestros Términos del servicio o Normas de la comunidad.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nPor favor, revisa:\n- Términos del servicio: {termsUrl}\n- Normas de la comunidad: {guidelinesUrl}\n\nProceso de apelación:\nSi crees que esta decisión fue incorrecta o injustificada, tienes 60 días para presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\nEn tu apelación:\n- Explica claramente por qué crees que la decisión fue incorrecta o injustificada\n- Aporta las pruebas o el contexto que consideres relevantes\n\nUn miembro del Equipo de seguridad de {product_name} revisará tu apelación y podrá pausar la eliminación pendiente hasta que se haya tomado una decisión final.\n\n– Equipo de seguridad de {product_name}"
},
"account_temp_banned": {
"subject": "Tu cuenta de {product_name} ha sido suspendida temporalmente",
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido suspendida temporalmente por violar nuestros Términos de Servicio o Normas de la Comunidad.\n\nDuración: {durationHours, plural,\n =1 {1 hora}\n other {# horas}\n}\nSuspendida hasta: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nDurante este tiempo, no podrás acceder a tu cuenta.\n\nPor favor, revisa:\n- Términos de Servicio: {termsUrl}\n- Normas de la Comunidad: {guidelinesUrl}\n\nSi crees que esta decisión fue incorrecta o injustificada, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico y explica claramente por qué crees que la decisión fue incorrecta. Revisaremos tu apelación y te responderemos con nuestra decisión.\n\n– Equipo de Seguridad de {product_name}"
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido suspendida temporalmente por infringir nuestros Términos del servicio o Normas de la comunidad.\n\nDuración: {durationHours, plural,\n =1 {1 hora}\n other {# horas}\n}\nSuspendida hasta: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nDurante este tiempo, no podrás acceder a tu cuenta.\n\nPor favor, revisa:\n- Términos del servicio: {termsUrl}\n- Normas de la comunidad: {guidelinesUrl}\n\nSi crees que esta decisión fue incorrecta o injustificada, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico y explica claramente por qué crees que la decisión fue incorrecta. Revisaremos tu apelación y te responderemos con nuestra decisión.\n\n– Equipo de seguridad de {product_name}"
},
"donation_confirmation": {
"subject": "Gracias por tu donación a {product_name}",
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} ha sido {interval, select,\n month {configurada}\n year {configurada}\n other {procesada}\n} con éxito.\n\nDetalles de la donación:\nMonto: {amount} {currency} {interval, select,\n month {por mes}\n year {por año}\n other {}\n}\n\nStripe te enviará un recibo separado con tu factura en PDF en breve. Esto incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas, {interval, select,\n month {y administrar o cancelar tu suscripción}\n year {y administrar o cancelar tu suscripción}\n other {y administrar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} ha sido {interval, select,\n month {configurada}\n year {configurada}\n other {procesada}\n} con éxito.\n\nDetalles de la donación:\nMonto: {amount} {interval, select,\n month {por mes}\n year {por año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y administrar o cancelar tu suscripción}\n year {y administrar o cancelar tu suscripción}\n other {y administrar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
},
"donation_magic_link": {
"subject": "Administra tus donaciones a {product_name}",
"body": "Hola:\n\nHaz clic en el siguiente enlace para acceder a tu portal de donantes:\n\n{manageUrl}\n\nEn el portal, puedes administrar suscripciones, descargar facturas y ver tu historial de donaciones.\n\nEste enlace vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste este enlace, puedes ignorar este correo electrónico de forma segura.\n\n– Equipo de {product_name}"
},
"dsa_report_verification": {
"subject": "Verifica tu correo electrónico para un informe de la Ley de Servicios Digitales",
"body": "Hola:\n\nUsa el código de verificación a continuación para enviar tu informe de la Ley de Servicios Digitales en {product_name}:\n\n{code}\n\nEste código vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de Seguridad de {product_name}"
"subject": "Verifica tu correo electrónico para enviar un reporte conforme a la Ley de Servicios Digitales",
"body": "Hola:\n\nUsa el código de verificación a continuación para enviar tu reporte conforme a la Ley de Servicios Digitales en {product_name}:\n\n{code}\n\nEste código vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de seguridad de {product_name}"
},
"email_change_new": {
"subject": "Verifica tu nuevo correo electrónico de {product_name}",
"body": "Hola {username}:\n\nIngresa este código en la aplicación para verificar tu nuevo correo electrónico de {product_name}:\n\n{code}\n\nEste código vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de {product_name}"
"subject": "Verifica tu nuevo correo electrónico en {product_name}",
"body": "Hola {username}:\n\nIngresa este código en la aplicación para verificar tu nuevo correo electrónico en {product_name}:\n\n{code}\n\nEste código vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de {product_name}"
},
"email_change_original": {
"subject": "Confirma tu cambio de correo electrónico de {product_name}",
"subject": "Confirma tu cambio de correo electrónico en {product_name}",
"body": "Hola {username}:\n\nRecibimos una solicitud para cambiar la dirección de correo electrónico de tu cuenta de {product_name}.\n\nPara confirmar este cambio, ingresa este código en la aplicación:\n\n{code}\n\nEste código vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, por favor, protege tu cuenta de inmediato.\n\n– Equipo de {product_name}"
},
"email_change_revert": {
"subject": "Tu correo electrónico de {product_name} fue cambiado",
"body": "Hola {username}:\n\nLa dirección de correo electrónico de tu cuenta de {product_name} se cambió a {newEmail}.\n\nSi realizaste este cambio, no es necesario que hagas nada. Si no lo hiciste, puedes revertir el cambio y proteger tu cuenta usando este enlace:\n\n{revertUrl}\n\nEsto restaurará tu correo electrónico anterior, cerrará tu sesión en todas partes, eliminará los números de teléfono vinculados, deshabilitará la autenticación multifactor y te pedirá que establezcas una nueva contraseña.\n\n– Equipo de Seguridad de {product_name}"
"subject": "Se cambió tu correo electrónico en {product_name}",
"body": "Hola {username}:\n\nLa dirección de correo electrónico de tu cuenta de {product_name} se cambió a {newEmail}.\n\nSi realizaste este cambio, no es necesario que hagas nada. Si no lo hiciste, puedes revertir el cambio y proteger tu cuenta usando este enlace:\n\n{revertUrl}\n\nEsto restaurará tu correo electrónico anterior, cerrará tu sesión en todas partes, eliminará los números de teléfono vinculados, deshabilitará la autenticación multifactor y te pedirá que establezcas una nueva contraseña.\n\n– Equipo de seguridad de {product_name}"
},
"email_verification": {
"subject": "Verifica tu dirección de correo electrónico de {product_name}",
"subject": "Verifica tu dirección de correo electrónico en {product_name}",
"body": "Hola {username}:\n\nPor favor, verifica la dirección de correo electrónico de tu cuenta de {product_name} haciendo clic en el siguiente enlace:\n\n{verifyUrl}\n\nSi no creaste una cuenta de {product_name}, puedes ignorar este correo electrónico de forma segura.\n\nEste enlace es válido por 24 horas.\n\n– Equipo de {product_name}"
},
"gift_chargeback_notification": {
@@ -76,20 +76,20 @@ const EMAIL_I18N_ES_419_MESSAGES = defineEmailI18nLocaleMessages({
"body": "Hola {username}:\n\nBuenas noticias: tu registro en {product_name} ha sido aprobado.\n\nAhora puedes iniciar sesión en la aplicación de {product_name} aquí:\n{channelsUrl}\n\nBienvenido a la comunidad de {product_name}.\n\n– Equipo de {product_name}"
},
"report_resolved": {
"subject": "Tu informe de {product_name} ha sido revisado",
"body": "Hola {username}:\n\nTu informe (ID: {reportId}) ha sido revisado por nuestro Equipo de Seguridad.{hasComment, select, yes {\n\nRespuesta del Equipo de Seguridad:\n{publicComment}} other {}}\n\nGracias por ayudar a mantener {product_name} seguro para todos. Nos tomamos todos los informes en serio y agradecemos tu contribución a la comunidad.\n\nSi tienes alguna pregunta o inquietud sobre este resultado, comunícate con {safety_email}.\n\n– Equipo de Seguridad de {product_name}"
"subject": "Se revisó tu reporte en {product_name}",
"body": "Hola {username}:\n\nNuestro equipo de seguridad revisó tu reporte (ID: {reportId}).{hasComment, select, yes {\n\nRespuesta del equipo de seguridad:\n{publicComment}} other {}}\n\nGracias por ayudar a mantener {product_name} seguro para todos. Nos tomamos todos los reportes en serio y agradecemos tu contribución a la comunidad.\n\nSi tienes alguna pregunta o inquietud sobre este resultado, comunícate con {safety_email}.\n\n– Equipo de seguridad de {product_name}"
},
"scheduled_deletion_notification": {
"subject": "Tu cuenta de {product_name} será eliminada permanentemente",
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido programada para su eliminación permanente.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nSi crees que esta decisión fue incorrecta, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\n– Equipo de Seguridad de {product_name}"
"body": "Hola {username}:\n\nSe ha programado la eliminación permanente de tu cuenta de {product_name}.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nSi crees que esta decisión fue incorrecta, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\n– Equipo de seguridad de {product_name}"
},
"self_deletion_scheduled": {
"subject": "La eliminación de tu cuenta de {product_name} está programada",
"body": "Hola {username}:\n\nSolicitaste eliminar tu cuenta de {product_name}. Tu cuenta está programada para su eliminación permanente el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}\n\nSi no solicitaste esto, inicia sesión en tu cuenta para cancelar la eliminación. También te recomendamos cambiar tu contraseña para proteger tu cuenta.\n\n– Equipo de {product_name}"
"body": "Hola {username}:\n\nSolicitaste eliminar tu cuenta de {product_name}. La eliminación permanente de tu cuenta está programada para el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}\n\nSi no solicitaste esto, inicia sesión en tu cuenta para cancelar la eliminación. También te recomendamos cambiar tu contraseña para proteger tu cuenta.\n\n– Equipo de {product_name}"
},
"unban_notification": {
"subject": "Se levantó la suspensión de tu cuenta de {product_name}",
"body": "Hola {username}:\n\nBuenas noticias: se levantó la suspensión de tu cuenta de {product_name}.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nAhora puedes iniciar sesión de nuevo y seguir usando {product_name} con normalidad.\n\n– Equipo de Seguridad de {product_name}"
"body": "Hola {username}:\n\nBuenas noticias: se levantó la suspensión de tu cuenta de {product_name}.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nAhora puedes iniciar sesión de nuevo y seguir usando {product_name} con normalidad.\n\n– Equipo de seguridad de {product_name}"
}
});
@@ -1,46 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
const EMAIL_I18N_ES_ES_MESSAGES = defineEmailI18nLocaleMessages({
"account_disabled_suspicious": {
"subject": "Tu cuenta de {product_name} ha sido deshabilitada temporalmente",
"body": "Hola {username}:\n\nHemos deshabilitado temporalmente tu cuenta de {product_name} porque hemos detectado actividad sospechosa.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nPara recuperar el acceso a tu cuenta, tendrás que restablecer tu contraseña:\n\n{forgotUrl}\n\nDespués de restablecer tu contraseña, podrás iniciar sesión de nuevo.\n\nSi crees que esto ha sido un error, ponte en contacto con nuestro equipo de soporte.\n\n– Equipo de Seguridad de {product_name}"
"body": "Hola {username}:\n\nHemos deshabilitado temporalmente tu cuenta de {product_name} porque hemos detectado actividad sospechosa.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nPara recuperar el acceso a tu cuenta, tendrás que restablecer tu contraseña:\n\n{forgotUrl}\n\nDespués de restablecer tu contraseña, podrás iniciar sesión de nuevo.\n\nSi crees que esto ha sido un error, ponte en contacto con nuestro equipo de soporte.\n\n– Equipo de seguridad de {product_name}"
},
"account_scheduled_deletion": {
"subject": "Tu cuenta de {product_name} será eliminada permanentemente",
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido programada para su eliminación permanente debido a infracciones de nuestros Términos de Servicio o Directrices de la Comunidad.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nPor favor, revisa:\n- Términos de Servicio: {termsUrl}\n- Directrices de la Comunidad: {guidelinesUrl}\n\nProceso de apelación:\nSi crees que esta decisión fue incorrecta o injustificada, tienes 60 días para presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\nEn tu apelación:\n- Explica claramente por qué crees que la decisión fue incorrecta o injustificada\n- Proporciona cualquier evidencia o contexto relevante\n\nUn miembro del Equipo de Seguridad de {product_name} revisará tu apelación y podrá pausar la eliminación pendiente hasta que se haya tomado una decisión final.\n\n– Equipo de Seguridad de {product_name}"
"body": "Hola {username}:\n\nSe ha programado la eliminación permanente de tu cuenta de {product_name} debido a infracciones de nuestras Condiciones del servicio o Normas de la comunidad.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nPor favor, revisa:\n- Condiciones del servicio: {termsUrl}\n- Normas de la comunidad: {guidelinesUrl}\n\nProceso de apelación:\nSi crees que esta decisión fue incorrecta o injustificada, tienes 60 días para presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\nEn tu apelación:\n- Explica claramente por qué crees que la decisión fue incorrecta o injustificada\n- Aporta las pruebas o el contexto que consideres relevantes\n\nUn miembro del Equipo de seguridad de {product_name} revisará tu apelación y podrá pausar la eliminación pendiente hasta que se haya tomado una decisión final.\n\n– Equipo de seguridad de {product_name}"
},
"account_temp_banned": {
"subject": "Tu cuenta de {product_name} ha sido suspendida temporalmente",
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido suspendida temporalmente por violar nuestros Términos de Servicio o Directrices de la Comunidad.\n\nDuración: {durationHours, plural,\n =1 {1 hora}\n other {# horas}\n}\nSuspendida hasta: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nDurante este tiempo, no podrás acceder a tu cuenta.\n\nPor favor, revisa:\n- Términos de Servicio: {termsUrl}\n- Directrices de la Comunidad: {guidelinesUrl}\n\nSi crees que esta decisión fue incorrecta o injustificada, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico y explica claramente por qué crees que la decisión fue incorrecta. Revisaremos tu apelación y te responderemos con nuestra decisión.\n\n– Equipo de Seguridad de {product_name}"
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido suspendida temporalmente por infringir nuestras Condiciones del servicio o Normas de la comunidad.\n\nDuración: {durationHours, plural,\n =1 {1 hora}\n other {# horas}\n}\nSuspendida hasta: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nDurante este tiempo, no podrás acceder a tu cuenta.\n\nPor favor, revisa:\n- Condiciones del servicio: {termsUrl}\n- Normas de la comunidad: {guidelinesUrl}\n\nSi crees que esta decisión fue incorrecta o injustificada, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico y explica claramente por qué crees que la decisión fue incorrecta. Revisaremos tu apelación y te responderemos con nuestra decisión.\n\n– Equipo de seguridad de {product_name}"
},
"donation_confirmation": {
"subject": "Gracias por tu donación a {product_name}",
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} se ha {interval, select,\n month {configurado}\n year {configurado}\n other {procesado}\n} correctamente.\n\nDetalles de la donación:\nCantidad: {amount} {currency} {interval, select,\n month {al mes}\n year {al año}\n other {}\n}\n\nStripe te enviará un recibo separado con tu factura en PDF en breve. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y gestionar o cancelar tu suscripción}\n year {y gestionar o cancelar tu suscripción}\n other {y gestionar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
"body": "Hola:\n\n¡Gracias por tu donación a {product_name}! Tu {interval, select,\n month {donación recurrente}\n year {donación recurrente}\n other {donación única}\n} se ha {interval, select,\n month {configurado}\n year {configurado}\n other {procesado}\n} correctamente.\n\nDetalles de la donación:\nImporte: {amount} {interval, select,\n month {al mes}\n year {al año}\n other {}\n}\n\nEn breve, Stripe te enviará por separado un correo electrónico con el recibo y la factura en PDF. Este recibo incluye todos los detalles de pago y se puede utilizar para fines fiscales.\n\nPuedes ver tu historial de donaciones, descargar facturas {interval, select,\n month {y gestionar o cancelar tu suscripción}\n year {y gestionar o cancelar tu suscripción}\n other {y gestionar futuras donaciones}\n} en cualquier momento usando este enlace:\n\n{manageUrl}\n\nTu apoyo ayuda a mantener {product_name} en funcionamiento. ¡Gracias!\n\n– Equipo de {product_name}"
},
"donation_magic_link": {
"subject": "Gestiona tus donaciones de {product_name}",
"subject": "Gestiona tus donaciones a {product_name}",
"body": "Hola:\n\nHaz clic en el siguiente enlace para acceder a tu portal de donantes:\n\n{manageUrl}\n\nEn el portal, puedes gestionar suscripciones, descargar facturas y ver tu historial de donaciones.\n\nEste enlace caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste este enlace, puedes ignorar este correo electrónico de forma segura.\n\n– Equipo de {product_name}"
},
"dsa_report_verification": {
"subject": "Verifica tu correo electrónico para un informe de la DSA",
"body": "Hola:\n\nUsa el siguiente código de verificación para enviar tu informe de la Ley de Servicios Digitales en {product_name}:\n\n{code}\n\nEste código caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de Seguridad de {product_name}"
"subject": "Verifica tu correo electrónico para enviar una denuncia conforme a la Ley de Servicios Digitales",
"body": "Hola:\n\nUsa el siguiente código de verificación para enviar tu denuncia conforme a la Ley de Servicios Digitales en {product_name}:\n\n{code}\n\nEste código caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de seguridad de {product_name}"
},
"email_change_new": {
"subject": "Verifica tu nuevo correo electrónico para {product_name}",
"body": "Hola {username}:\n\nIntroduce este código en la aplicación para verificar tu nuevo correo electrónico para {product_name}:\n\n{code}\n\nEste código caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de {product_name}"
"subject": "Verifica tu nuevo correo electrónico en {product_name}",
"body": "Hola {username}:\n\nIntroduce este código en la aplicación para verificar tu nuevo correo electrónico en {product_name}:\n\n{code}\n\nEste código caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, puedes ignorar este correo electrónico.\n\n– Equipo de {product_name}"
},
"email_change_original": {
"subject": "Confirma tu cambio de correo electrónico para {product_name}",
"subject": "Confirma tu cambio de correo electrónico en {product_name}",
"body": "Hola {username}:\n\nHemos recibido una solicitud para cambiar la dirección de correo electrónico de tu cuenta de {product_name}.\n\nPara confirmar este cambio, introduce este código en la aplicación:\n\n{code}\n\nEste código caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, protege tu cuenta de inmediato.\n\n– Equipo de {product_name}"
},
"email_change_revert": {
"subject": "Tu correo electrónico para {product_name} ha sido cambiado",
"body": "Hola {username}:\n\nLa dirección de correo electrónico de tu cuenta de {product_name} ha sido cambiada a {newEmail}.\n\nSi realizaste este cambio, no es necesario que hagas nada. Si no lo hiciste, puedes revertir el cambio y proteger tu cuenta usando este enlace:\n\n{revertUrl}\n\nEsto restaurará tu correo electrónico anterior, cerrará tu sesión en todas partes, eliminará los números de teléfono vinculados, deshabilitará la autenticación multifactor y te pedirá que establezcas una nueva contraseña.\n\n– Equipo de Seguridad de {product_name}"
"subject": "Se ha cambiado tu correo electrónico en {product_name}",
"body": "Hola {username}:\n\nLa dirección de correo electrónico de tu cuenta de {product_name} ha sido cambiada a {newEmail}.\n\nSi realizaste este cambio, no es necesario que hagas nada. Si no lo hiciste, puedes revertir el cambio y proteger tu cuenta usando este enlace:\n\n{revertUrl}\n\nEsto restaurará tu correo electrónico anterior, cerrará tu sesión en todas partes, eliminará los números de teléfono vinculados, deshabilitará la autenticación multifactor y te pedirá que establezcas una nueva contraseña.\n\n– Equipo de seguridad de {product_name}"
},
"email_verification": {
"subject": "Verifica tu dirección de correo electrónico para {product_name}",
"subject": "Verifica tu dirección de correo electrónico en {product_name}",
"body": "Hola {username}:\n\nVerifica la dirección de correo electrónico de tu cuenta de {product_name} haciendo clic en el siguiente enlace:\n\n{verifyUrl}\n\nSi no creaste una cuenta de {product_name}, puedes ignorar este correo electrónico.\n\nEste enlace es válido durante 24 horas.\n\n– Equipo de {product_name}"
},
"gift_chargeback_notification": {
@@ -56,8 +56,8 @@ const EMAIL_I18N_ES_ES_MESSAGES = defineEmailI18nLocaleMessages({
"body": "Hola {username}:\n\nNo hemos visto ninguna actividad en tu cuenta de {product_name} desde el {lastActiveDate, date, full}.\n\nSi no inicias sesión antes del {deletionDate, date, full} a las {deletionDate, time, short}, tu cuenta será eliminada permanentemente debido a la inactividad.\n\nInicia sesión aquí:\n{loginUrl}\n\nSi has usado {product_name} recientemente, ponte en contacto con nuestro equipo de soporte de inmediato.\n\n– Equipo de {product_name}"
},
"ip_authorization": {
"subject": "Autorizar inicio de sesión desde una nueva dirección IP",
"body": "Hola {username}:\n\nHemos detectado un intento de inicio de sesión en tu cuenta de {product_name} desde una nueva IP:\n\nDirección IP: {ipAddress}\nUbicación: {location}\n\nSi fuiste tú, autoriza esta dirección IP haciendo clic en el siguiente enlace:\n\n{authUrl}\n\nSi no intentaste iniciar sesión, cambia tu contraseña de inmediato.\n\nEste enlace es válido durante 30 minutos.\n\n– Equipo de {product_name}"
"subject": "Autoriza el inicio de sesión desde una nueva dirección IP",
"body": "Hola {username}:\n\nHemos detectado un intento de inicio de sesión en tu cuenta de {product_name} desde una nueva dirección IP:\n\nDirección IP: {ipAddress}\nUbicación: {location}\n\nSi fuiste tú, autoriza esta dirección IP haciendo clic en el siguiente enlace:\n\n{authUrl}\n\nSi no intentaste iniciar sesión, cambia tu contraseña de inmediato.\n\nEste enlace es válido durante 30 minutos.\n\n– Equipo de {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Confirma el acceso a tus códigos de respaldo de {product_name}",
@@ -76,20 +76,20 @@ const EMAIL_I18N_ES_ES_MESSAGES = defineEmailI18nLocaleMessages({
"body": "Hola {username}:\n\nBuenas noticias: tu registro en {product_name} ha sido aprobado.\n\nAhora puedes iniciar sesión en la aplicación de {product_name} aquí:\n{channelsUrl}\n\nBienvenido a la comunidad de {product_name}.\n\n– Equipo de {product_name}"
},
"report_resolved": {
"subject": "Tu informe en {product_name} ha sido revisado",
"body": "Hola {username}:\n\nTu informe (ID: {reportId}) ha sido revisado por nuestro Equipo de Seguridad.{hasComment, select, yes {\n\nRespuesta del Equipo de Seguridad:\n{publicComment}} other {}}\n\nGracias por ayudarnos a mantener {product_name} seguro para todos. Nos tomamos todos los informes en serio y agradecemos tu contribución a la comunidad.\n\nSi tienes alguna pregunta o inquietud sobre este resultado, ponte en contacto con {safety_email}.\n\n– Equipo de Seguridad de {product_name}"
"subject": "Se ha revisado tu denuncia en {product_name}",
"body": "Hola {username}:\n\nNuestro equipo de seguridad ha revisado tu denuncia (ID: {reportId}).{hasComment, select, yes {\n\nRespuesta del equipo de seguridad:\n{publicComment}} other {}}\n\nGracias por ayudarnos a mantener {product_name} seguro para todos. Nos tomamos todas las denuncias en serio y agradecemos tu contribución a la comunidad.\n\nSi tienes alguna pregunta o inquietud sobre este resultado, ponte en contacto con {safety_email}.\n\n– Equipo de seguridad de {product_name}"
},
"scheduled_deletion_notification": {
"subject": "Tu cuenta de {product_name} será eliminada permanentemente",
"body": "Hola {username}:\n\nTu cuenta de {product_name} ha sido programada para su eliminación permanente.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nSi crees que esta decisión fue incorrecta, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\n– Equipo de Seguridad de {product_name}"
"body": "Hola {username}:\n\nSe ha programado la eliminación permanente de tu cuenta de {product_name}.\n\nEliminación programada: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nEsta es una medida grave. Los datos de tu cuenta se eliminarán permanentemente en la fecha programada.\n\nSi crees que esta decisión fue incorrecta, puedes presentar una apelación. Envía un correo electrónico a {appeals_email} desde esta dirección de correo electrónico.\n\n– Equipo de seguridad de {product_name}"
},
"self_deletion_scheduled": {
"subject": "La eliminación de tu cuenta de {product_name} está programada",
"body": "Hola {username}:\n\nSolicitaste eliminar tu cuenta de {product_name}. Tu cuenta está programada para su eliminación permanente el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}\n\nSi no solicitaste esto, inicia sesión en tu cuenta para cancelar la eliminación. También te recomendamos cambiar tu contraseña para proteger tu cuenta.\n\n– Equipo de {product_name}"
"body": "Hola {username}:\n\nSolicitaste eliminar tu cuenta de {product_name}. La eliminación permanente de tu cuenta está programada para el:\n\n{deletionDate, date, full} a las {deletionDate, time, short}\n\nSi no solicitaste esto, inicia sesión en tu cuenta para cancelar la eliminación. También te recomendamos cambiar tu contraseña para proteger tu cuenta.\n\n– Equipo de {product_name}"
},
"unban_notification": {
"subject": "Se ha levantado la suspensión de tu cuenta de {product_name}",
"body": "Hola {username}:\n\nBuenas noticias: se ha levantado la suspensión de tu cuenta de {product_name}.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nAhora puedes volver a iniciar sesión y seguir usando {product_name} con normalidad.\n\n– Equipo de Seguridad de {product_name}"
"body": "Hola {username}:\n\nBuenas noticias: se ha levantado la suspensión de tu cuenta de {product_name}.\n\n{reason, select,\n null {}\n other {Motivo: {reason}}\n}\n\nAhora puedes volver a iniciar sesión y seguir usando {product_name} con normalidad.\n\n– Equipo de seguridad de {product_name}"
}
});
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
import {defineEmailI18nLocaleMessages} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
"account_disabled_suspicious": {
@@ -9,23 +9,23 @@ const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
},
"account_scheduled_deletion": {
"subject": "Käyttäjätilisi {product_name}-palvelussa poistetaan pysyvästi",
"body": "Hei {username},\n\n{product_name}-käyttäjätilisi on ajoitettu poistettavaksi pysyvästi käyttöehtojemme tai yhteisön sääntöjemme rikkomusten vuoksi.\n\nAjoitettu poisto: {deletionDate, date, full} klo {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Syy: {reason}}\n}\n\nTämä on vakava toimenpide. Tilisi tiedot poistetaan pysyvästi ajoitettuna päivänä.\n\nTarkista:\n- Käyttöehdot: {termsUrl}\n- Yhteisön säännöt: {guidelinesUrl}\n\nValitusprosessi:\nJos uskot, että tämä päätös oli virheellinen tai perusteeton, sinulla on 60 päivää aikaa tehdä valitus. Lähetä sähköpostia osoitteeseen {appeals_email} tästä sähköpostiosoitteesta.\n\nValituksessasi:\n- Selitä selkeästi, miksi uskot päätöksen olleen virheellinen tai perusteeton\n- Anna kaikki asiaankuuluvat todisteet tai konteksti\n\n{product_name}-turvallisuustiimin jäsen tarkistaa valituksesi ja voi keskeyttää vireillä olevan poiston, kunnes lopullinen päätös on tehty.\n\n– {product_name}-turvallisuustiimi"
"body": "Hei {username},\n\n{product_name}-käyttäjätilisi on ajoitettu poistettavaksi pysyvästi käyttöehtojemme tai yhteisösääntöjemme rikkomisen vuoksi.\n\nAjoitettu poisto: {deletionDate, date, full} klo {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Syy: {reason}}\n}\n\nTämä on vakava toimenpide. Tilisi tiedot poistetaan pysyvästi ajoitettuna päivänä.\n\nTutustu:\n- Käyttöehdot: {termsUrl}\n- Yhteisön säännöt: {guidelinesUrl}\n\nValitusprosessi:\nJos uskot, että tämä päätös oli virheellinen tai perusteeton, sinulla on 60 päivää aikaa tehdä valitus. Lähetä sähköpostia osoitteeseen {appeals_email} tästä sähköpostiosoitteesta.\n\nValituksessasi:\n- Selitä selkeästi, miksi uskot päätöksen olleen virheellinen tai perusteeton\n- Liitä mukaan asiaankuuluvat todisteet tai taustatiedot\n\n{product_name}-turvallisuustiimin jäsen käsittelee valituksesi ja voi keskeyttää vireillä olevan poiston, kunnes lopullinen päätös on tehty.\n\n– {product_name}-turvallisuustiimi"
},
"account_temp_banned": {
"subject": "Käyttäjätilisi {product_name}-palvelussa on tilapäisesti jäädytetty",
"body": "Hei {username},\n\n{product_name}-käyttäjätilisi on jäädytetty tilapäisesti käyttöehtojemme tai yhteisön sääntöjemme rikkomisen vuoksi.\n\nKesto: {durationHours, plural,\n =1 {1 tunti}\n other {# tuntia}\n}\nJäädytettynä {bannedUntil, date, full} klo {bannedUntil, time, short} asti\n\n{reason, select,\n null {}\n other {Syy: {reason}}\n}\n\nTänä aikana et voi käyttää tiliäsi.\n\nTarkista:\n- Käyttöehdot: {termsUrl}\n- Yhteisön säännöt: {guidelinesUrl}\n\nJos uskot, että tämä päätös oli virheellinen tai perusteeton, voit tehdä valituksen. Lähetä sähköpostia osoitteeseen {appeals_email} tästä sähköpostiosoitteesta ja selitä selkeästi, miksi uskot päätöksen olleen virheellinen. Tarkistamme valituksesi ja vastaamme päätöksellämme.\n\n– {product_name}-turvallisuustiimi"
"body": "Hei {username},\n\n{product_name}-käyttäjätilisi on jäädytetty tilapäisesti käyttöehtojemme tai yhteisösääntöjemme rikkomisen vuoksi.\n\nKesto: {durationHours, plural,\n =1 {1 tunti}\n other {# tuntia}\n}\nJäädytettynä {bannedUntil, date, full} klo {bannedUntil, time, short} asti\n\n{reason, select,\n null {}\n other {Syy: {reason}}\n}\n\nTänä aikana et voi käyttää tiliäsi.\n\nTutustu:\n- Käyttöehdot: {termsUrl}\n- Yhteisön säännöt: {guidelinesUrl}\n\nJos uskot, että tämä päätös oli virheellinen tai perusteeton, voit tehdä valituksen. Lähetä sähköpostia osoitteeseen {appeals_email} tästä sähköpostiosoitteesta ja selitä selkeästi, miksi uskot päätöksen olleen virheellinen. Käsittelemme valituksesi ja ilmoitamme sinulle päätöksemme.\n\n– {product_name}-turvallisuustiimi"
},
"donation_confirmation": {
"subject": "Kiitos {product_name}-lahjoituksestasi",
"body": "Hei,\n\nKiitos {product_name}-lahjoituksestasi! {interval, select,\n month {Toistuva lahjoituksesi}\n year {Toistuva lahjoituksesi}\n other {Kertalahjoituksesi}\n} on {interval, select,\n month {asetettu}\n year {asetettu}\n other {käsitelty}\n} onnistuneesti.\n\nLahjoituksen tiedot:\nSumma: {amount} {currency} {interval, select,\n month {kuukaudessa}\n year {vuodessa}\n other {}\n}\n\nStripe lähettää sinulle erillisen kuitin PDF-laskun kanssa pian. Tämä sisältää kaikki maksutiedot ja sitä voidaan käyttää verotuksessa.\n\nVoit tarkastella lahjoitushistoriaasi, ladata laskuja {interval, select,\n month {ja hallinnoida tai peruuttaa tilaustasi}\n year {ja hallinnoida tai peruuttaa tilaustasi}\n other {ja hallita tulevia lahjoituksia}\n} milloin tahansa käyttämällä tätä linkkiä:\n\n{manageUrl}\n\nTukesi auttaa pitämään {product_name}-palvelun toiminnassa. Kiitos!\n\n– {product_name}-tiimi"
"body": "Hei,\n\nKiitos {product_name}-lahjoituksestasi! {interval, select,\n month {Toistuva lahjoituksesi}\n year {Toistuva lahjoituksesi}\n other {Kertalahjoituksesi}\n} on {interval, select,\n month {käynnistetty}\n year {käynnistetty}\n other {käsitelty}\n} onnistuneesti.\n\nLahjoituksen tiedot:\nSumma: {amount} {interval, select,\n month {kuukaudessa}\n year {vuodessa}\n other {}\n}\n\nStripe lähettää sinulle pian sähköpostitse erillisen kuitin sekä PDF-laskun. Niissä ovat kaikki maksutiedot, ja niitä voi käyttää verotuksessa.\n\nVoit tarkastella lahjoitushistoriaasi, ladata laskuja {interval, select,\n month {ja hallinnoida tai peruuttaa tilaustasi}\n year {ja hallinnoida tai peruuttaa tilaustasi}\n other {ja hallita tulevia lahjoituksia}\n} milloin tahansa käyttämällä tätä linkkiä:\n\n{manageUrl}\n\nTukesi auttaa pitämään {product_name}-palvelun toiminnassa. Kiitos!\n\n– {product_name}-tiimi"
},
"donation_magic_link": {
"subject": "Hallinnoi {product_name}-palvelun lahjoituksiasi",
"subject": "Hallitse {product_name}-lahjoituksiasi",
"body": "Hei,\n\nNapsauta alla olevaa linkkiä päästäksesi lahjoittajaportaaliisi:\n\n{manageUrl}\n\nPortaalissa voit hallinnoida tilauksia, ladata laskuja ja tarkastella lahjoitushistoriaasi.\n\nTämä linkki vanhenee {expiresAt, date, full} klo {expiresAt, time, short}.\n\nJos et pyytänyt tätä linkkiä, voit jättää tämän sähköpostin turvallisesti huomiotta.\n\n– {product_name}-tiimi"
},
"dsa_report_verification": {
"subject": "Vahvista sähköpostiosoite DSA-raporttia varten",
"body": "Hei,\n\nKäytä alla olevaa vahvistuskoodia lähettääksesi DSA-raporttisi {product_name}-palveluun:\n\n{code}\n\nTämä koodi vanhenee {expiresAt, date, full} klo {expiresAt, time, short}.\n\nJos et pyytänyt tätä, voit jättää tämän sähköpostin huomiotta.\n\n– {product_name}-turvallisuustiimi"
"subject": "Vahvista sähköpostiosoitteesi DSA-ilmoitusta varten",
"body": "Hei,\n\nKäytä alla olevaa vahvistuskoodia lähettääksesi digipalvelusäädöksen mukaisen ilmoituksen {product_name}-palveluun:\n\n{code}\n\nTämä koodi vanhenee {expiresAt, date, full} klo {expiresAt, time, short}.\n\nJos et pyytänyt tätä, voit jättää tämän sähköpostin huomiotta.\n\n– {product_name}-turvallisuustiimi"
},
"email_change_new": {
"subject": "Vahvista uusi sähköpostiosoitteesi {product_name}-tilillä",
@@ -33,7 +33,7 @@ const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
},
"email_change_original": {
"subject": "Vahvista sähköpostiosoitteen muutos {product_name}-tilillä",
"body": "Hei {username},\n\nSaimme pyynnön muuttaa sähköpostiosoitetta {product_name}-tililläsi.\n\nVahvistaaksesi tämän muutoksen, syötä tämä koodi sovellukseen:\n\n{code}\n\nTämä koodi vanhenee {expiresAt, date, full} klo {expiresAt, time, short}.\n\nJos et pyytänyt tätä, turvaa tilisi välittömästi.\n\n– {product_name}-tiimi"
"body": "Hei {username},\n\nSaimme pyynnön muuttaa sähköpostiosoitetta {product_name}-tililläsi.\n\nSyötä tämä koodi sovellukseen vahvistaaksesi muutoksen:\n\n{code}\n\nTämä koodi vanhenee {expiresAt, date, full} klo {expiresAt, time, short}.\n\nJos et pyytänyt tätä, turvaa tilisi välittömästi.\n\n– {product_name}-tiimi"
},
"email_change_revert": {
"subject": "{product_name}-tilisi sähköpostiosoite on vaihdettu",
@@ -57,15 +57,15 @@ const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
},
"ip_authorization": {
"subject": "Hyväksy kirjautuminen uudesta IP-osoitteesta",
"body": "Hei {username},\n\nHavaitsimme kirjautumisyrityksen {product_name}-tilillesi uudesta IP-osoitteesta:\n\nIP-osoite: {ipAddress}\nSijainti: {location}\n\nJos tämä olit sinä, valtuuta tämä IP-osoite napsauttamalla alla olevaa linkkiä:\n\n{authUrl}\n\nJos et yrittänyt kirjautua sisään, vaihda salasanasi välittömästi.\n\nTämä linkki on voimassa 30 minuuttia.\n\n– {product_name}-tiimi"
"body": "Hei {username},\n\nHavaitsimme kirjautumisyrityksen {product_name}-tilillesi uudesta IP-osoitteesta:\n\nIP-osoite: {ipAddress}\nSijainti: {location}\n\nJos yritit kirjautua sisään, hyväksy tämä IP-osoite napsauttamalla alla olevaa linkkiä:\n\n{authUrl}\n\nJos et yrittänyt kirjautua sisään, vaihda salasanasi välittömästi.\n\nTämä linkki on voimassa 30 minuuttia.\n\n– {product_name}-tiimi"
},
"mfa_backup_codes_view": {
"subject": "Vahvista pääsy {product_name}-tilisi varmuuskoodeihin",
"body": "Hei {username},\n\nSaimme pyynnön tarkastella varmuuskoodeja {product_name}-tililläsi.\n\nVahvistaaksesi tämän pyynnön, syötä tämä koodi sovellukseen:\n\n{code}\n\nTämä koodi vanhenee {expiresAt, date, full} klo {expiresAt, time, short}.\n\nJos et pyytänyt tätä, joku saattaa päästä tilillesi. Vaihda salasanasi välittömästi.\n\n– {product_name}-tiimi"
"subject": "Vahvista pääsy {product_name}-tilisi varakoodeihin",
"body": "Hei {username},\n\nSaimme pyynnön tarkastella varakoodeja {product_name}-tililläsi.\n\nSyötä tämä koodi sovellukseen vahvistaaksesi pyynnön:\n\n{code}\n\nTämä koodi vanhenee {expiresAt, date, full} klo {expiresAt, time, short}.\n\nJos et pyytänyt tätä, joku saattaa päästä tilillesi. Vaihda salasanasi välittömästi.\n\n– {product_name}-tiimi"
},
"password_change_verification": {
"subject": "Vahvista salasanan muutos {product_name}-tilillä",
"body": "Hei {username},\n\nSaimme pyynnön muuttaa salasanaa {product_name}-tililläsi.\n\nVahvistaaksesi tämän muutoksen, syötä tämä koodi sovellukseen:\n\n{code}\n\nTämä koodi vanhenee {expiresAt}.\n\nJos et pyytänyt tätä, joku saattaa päästä tilillesi. Vaihda salasanasi välittömästi ja ota käyttöön kaksivaiheinen todennus.\n\n– {product_name}-tiimi"
"body": "Hei {username},\n\nSaimme pyynnön muuttaa salasanaa {product_name}-tililläsi.\n\nSyötä tämä koodi sovellukseen vahvistaaksesi muutoksen:\n\n{code}\n\nTämä koodi vanhenee {expiresAt}.\n\nJos et pyytänyt tätä, joku saattaa päästä tilillesi. Vaihda salasanasi välittömästi ja ota käyttöön kaksivaiheinen todennus.\n\n– {product_name}-tiimi"
},
"password_reset": {
"subject": "Nollaa {product_name}-salasanasi",
@@ -76,8 +76,8 @@ const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
"body": "Hei {username},\n\nHyviä uutisia: {product_name}-rekisteröitymisesi on hyväksytty.\n\nVoit nyt kirjautua {product_name}-sovellukseen täältä:\n{channelsUrl}\n\nTervetuloa {product_name}-yhteisöön.\n\n– {product_name}-tiimi"
},
"report_resolved": {
"subject": "{product_name}-raporttisi on tarkistettu",
"body": "Hei {username},\n\nTurvallisuustiimimme on tarkistanut raporttisi (ID: {reportId}).{hasComment, select, yes {\n\nTurvallisuustiimin vastaus:\n{publicComment}} other {}}\n\nKiitos, että autat pitämään {product_name}-palvelun turvallisena kaikille. Otamme kaikki raportit vakavasti ja arvostamme panostasi yhteisöön.\n\nJos sinulla on kysyttävää tai huolenaiheita tästä tuloksesta, ota yhteyttä osoitteeseen {safety_email}.\n\n– {product_name}-turvallisuustiimi"
"subject": "{product_name}-ilmoituksesi on käsitelty",
"body": "Hei {username},\n\nTurvallisuustiimimme on käsitellyt ilmoituksesi (ID: {reportId}).{hasComment, select, yes {\n\nTurvallisuustiimin vastaus:\n{publicComment}} other {}}\n\nKiitos, että autat pitämään {product_name}-palvelun turvallisena kaikille. Otamme kaikki ilmoitukset vakavasti ja arvostamme panostasi yhteisöön.\n\nJos sinulla on kysyttävää tai huolenaiheita tästä päätöksestä, ota yhteyttä osoitteeseen {safety_email}.\n\n– {product_name}-turvallisuustiimi"
},
"scheduled_deletion_notification": {
"subject": "Käyttäjätilisi {product_name}-palvelussa poistetaan pysyvästi",

Some files were not shown because too many files have changed in this diff Show More