Merge remote-tracking branch 'origin/main' into feat/multi-account-push

This commit is contained in:
lucamzanon
2026-09-30 08:43:20 +02:00
588 changed files with 35288 additions and 5317 deletions
+14 -1
View File
@@ -1,16 +1,29 @@
node_modules
.next
out
lite-image
.git
.gitignore
.env*
!.env.example
!.env.dev.example
scripts/
!scripts/lite/
TODO.md
*.md
!README.md
# Sibling projects / test harness - not part of the webmail image
examples/
# Unit tests: `next build` type-checks every .ts file, and some tests import
# routes the Dockerfile deletes (app/api/dev-jmap). The Dockerfile deletes the
# tests as well, since a remote Git context skips this file.
**/__tests__
**/*.test.ts
**/*.test.tsx
integration/
e2e/
repos/
# Runtime state of a checkout that has been run (admin password hash,
# plugin signing key, audit log, synced settings) - never part of an image
data/
local-data/
**/node_modules
+30 -3
View File
@@ -10,7 +10,13 @@
APP_NAME=Bulwark Webmail
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
# The webmail server checks every sign-in against this URL itself, so it must
# resolve and be trusted from here (e.g. inside the container), not only from
# the browser. If the mail server uses a self-signed or private-CA certificate,
# point NODE_EXTRA_CA_CERTS at that certificate or its CA; otherwise calendar
# subscriptions and other server-side features fail with "Not authenticated".
JMAP_SERVER_URL=https://your-jmap-server.com
# NODE_EXTRA_CA_CERTS=/certs/mail-ca.pem
# Allow users to specify a custom JMAP server URL on the login form.
# When enabled, a "JMAP Server" field appears on the login page.
@@ -64,11 +70,23 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Leave unset to use the authorization_endpoint from discovery.
# OAUTH_AUTHORIZE_URL=https://login.your-brand.example.com/application/o/authorize/
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
# OAuth issuer's public hostname resolves to an internal IP from this server.
# Allow OAuth endpoints on other hosts to resolve to private (RFC-1918 /
# loopback) addresses. Off by default as an SSRF guard. Endpoints on the
# issuer's own host are always allowed, so split-DNS deployments where the
# issuer's public hostname resolves to an internal IP work without this. Enable
# only when the IdP advertises endpoints on a different internal host.
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
# Signing out of an SSO account also signs out of the identity provider,
# through the end_session_endpoint it advertises (RP-initiated logout). Set to
# false when the provider is shared with apps that should stay signed in.
# OAUTH_END_SESSION=false
# Where the identity provider sends the browser after signing the user out.
# Register it with the provider as a post-logout redirect URI first, or it
# will refuse the logout. Unset: the provider shows its own signed-out page.
# OAUTH_POST_LOGOUT_REDIRECT_URI=https://mail.example.com/en/login
# Replace the scopes requested at authorization. Space-separated. Leave unset
# to use the defaults the client already asks for.
# OAUTH_SCOPES=openid email profile offline_access
@@ -352,6 +370,10 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# How many trusted reverse proxies sit in front of the app. The client IP is
# taken that many entries from the right of X-Forwarded-For, so an attacker
# can't spoof it by prepending values. Default: 1.
# Without any reverse proxy the header is whatever the client sends, so
# per-IP limits and audit-log addresses can be forged; admin sign-in then
# still stops at 50 attempts per 15 minutes across all clients. Put the
# app behind a proxy that appends X-Forwarded-For.
# TRUSTED_PROXY_DEPTH=2
# Allow search engines to index the app (robots.txt / noindex). Default: false.
@@ -432,6 +454,11 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# them unset keeps the feature fully off. Treat the secret and the master
# password as you would a root credential.
#
# The master password never leaves the server: each handoff uses it once to
# create an app password on the target mailbox, valid for 8 hours and
# revoked on sign-out. The target account's role therefore needs the
# app-password permissions (Stalwart's default user role has them).
#
# BULWARK_JWT_AUTH_SECRET= # required, >= 32 characters
# BULWARK_STALWART_MASTER_USER= # required, e.g. master@example.com
# BULWARK_STALWART_MASTER_PASSWORD= # required
+2
View File
@@ -0,0 +1,2 @@
# Sourced by /bin/sh inside the Lite container image: CRLF would break it.
*.envsh text eol=lf
+89
View File
@@ -0,0 +1,89 @@
name: Deploy demo
# Keeps https://demo.bulwarkmail.org on the published release image. It used to
# follow ghcr.io/bulwarkmail/webmail:latest through Watchtower; now the deploy
# is a step of its own, so it shows up under Deployments, can be triggered by
# hand and can be rolled back.
#
# Runs after "Publish Docker Image on Release" succeeds (the image has to exist
# before the server can pull it), or on demand:
#
# gh workflow run deploy-demo.yml -f action=deploy -f tag=1.10.0
# gh workflow run deploy-demo.yml -f action=rollback
#
# Nothing is built here. The server script /usr/local/bin/bulwark-demo-deploy
# (deploy/demo-deploy.sh) pulls the tag, recreates the container, health-checks
# it and rolls back on failure. A pre-release publishes no :latest, so the
# deploy that follows it finds the same image and does nothing.
on:
workflow_run:
workflows: ["Publish Docker Image on Release"]
types: [completed]
workflow_dispatch:
inputs:
action:
description: "What to do"
type: choice
options: [deploy, rollback]
default: deploy
tag:
description: "Image tag to deploy"
type: string
default: latest
permissions:
contents: read
concurrency:
group: deploy-demo
cancel-in-progress: false
env:
DEPLOY_HOST: 62.176.249.204
DEPLOY_USER: ubuntu
# ED25519 host key of mail.rath.li (SHA256:KJU3AEoj23/nBmHDF5w8o+wfRQcQTLdg3HHBrmlMdSQ).
DEPLOY_HOST_KEY: "62.176.249.204 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINOvbgUpI8cbxGf9nLOZlaG+0xd/bkJosDpBTyMxuurC"
jobs:
deploy:
name: ${{ inputs.action == 'rollback' && 'Roll back' || 'Deploy' }}
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
# A workflow_run has no inputs; it always deploys the release tag.
ACTION: ${{ inputs.action || 'deploy' }}
TAG: ${{ inputs.tag || 'latest' }}
environment:
name: demo
url: https://demo.bulwarkmail.org
steps:
- name: Set up SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key && chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$DEPLOY_HOST_KEY" > ~/.ssh/known_hosts
- name: Deploy
if: env.ACTION == 'deploy'
run: ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "deploy $TAG"
- name: Roll back
if: env.ACTION == 'rollback'
run: ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" rollback
- name: Check the live demo
run: |
for i in $(seq 1 10); do
code=$(curl -s -o /dev/null -w '%{http_code}' https://demo.bulwarkmail.org/api/health)
[ "$code" = 200 ] && { echo "live: $code"; exit 0; }
sleep 3
done
echo "the demo answered $code"; exit 1
- name: Show what is running
if: always()
run: ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" status
+191
View File
@@ -0,0 +1,191 @@
name: Publish Lite Docker Image
# Bulwark Lite as a container (Dockerfile.lite): the static export behind an
# unprivileged nginx. Every run first builds a demo image and drives it with
# the Lite smoke suite, so the nginx routing and headers are tested, not only
# the files. Then:
# - release: ghcr.io/<repo>-lite (semver tags, latest unless prerelease)
# - push to main: ghcr.io/<repo>-lite-beta (latest + sha)
# - pull request: smoke test only
# Published images get a signed build provenance attestation, pushed to GHCR
# next to them: `gh attestation verify oci://<image>:<tag> --owner bulwarkmail`.
on:
release:
types: [published]
push:
branches: [main]
paths: &paths
- "Dockerfile.lite"
- ".dockerignore"
- "scripts/lite/**"
- "app/**"
- "components/**"
- "contexts/**"
- "hooks/**"
- "lib/**"
- "stores/**"
- "locales/**"
- "public/**"
- "next.config.ts"
- "package.json"
- "package-lock.json"
- ".github/workflows/docker-publish-lite.yml"
pull_request:
paths: *paths
workflow_dispatch:
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build demo image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.lite
load: true
tags: bulwark-lite:smoke
build-args: |
GIT_COMMIT=${{ github.sha }}
LITE_DEMO_MODE=true
LITE_LOCALES=en,de
cache-from: type=gha,scope=lite-smoke
cache-to: type=gha,mode=max,scope=lite-smoke
# Read-only root, like a hardened Kubernetes pod: nginx only needs /tmp
# and the directory the entrypoint renders its config into.
- name: Start container
run: |
docker run -d --name lite -p 4173:8080 \
--read-only --tmpfs /tmp --tmpfs /etc/nginx/conf.d:uid=101,gid=101 \
-e LITE_CSP_CONNECT_SRC=https://mail.example.com \
bulwark-lite:smoke
for i in $(seq 1 30); do
curl -fsS http://localhost:4173/lite-build.json && exit 0
sleep 1
done
docker logs lite
exit 1
- name: A mounted config.json replaces the baked one
run: |
echo '{"appName":"Mounted","demoMode":true}' > "$RUNNER_TEMP/config.json"
docker run -d --name lite-mounted -p 4174:8080 \
-v "$RUNNER_TEMP/config.json:/usr/share/nginx/html/config.json:ro" \
bulwark-lite:smoke
for i in $(seq 1 30); do
curl -fsS http://localhost:4174/config.json | grep -q '"Mounted"' && break
[ "$i" = 30 ] && { docker logs lite-mounted; exit 1; }
sleep 1
done
docker rm -f lite-mounted
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Run smoke test against the container
env:
LITE_SMOKE_BASE_URL: http://localhost:4173
LITE_SMOKE_EXPECT_CONNECT_SRC: https://mail.example.com
run: npm run test:lite-smoke
- name: Container logs
if: failure()
run: docker logs lite
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: lite-container-smoke-report
path: playwright-report
retention-days: 7
publish:
needs: smoke
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
id-token: write
attestations: write
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}-lite${{ github.event_name != 'release' && '-beta' || '' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.event_name != 'release' || !github.event.release.prerelease }}
type=sha,enable=${{ github.event_name != 'release' }}
type=semver,pattern=v{{version}}
type=semver,pattern={{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=semver,pattern={{major}}
# The export is built once on this runner (Dockerfile.lite pins the
# builder to $BUILDPLATFORM) and the final stage only COPYs, so both
# platforms come out of one job without QEMU.
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.lite
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT=${{ github.sha }}
cache-from: type=gha,scope=lite
cache-to: type=gha,mode=max,scope=lite
- name: Inspect image
run: docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
- name: Attest build provenance
uses: actions/attest@v4
with:
subject-name: ${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
create-storage-record: false
+34 -2
View File
@@ -1,5 +1,11 @@
name: Publish Docker Image on Release
# `latest` moves only for a published stable release, or for a manual run
# dispatched on a stable release tag (to rebuild one). A dispatch on a
# branch builds nothing that the demo deploy or Watchtower users follow.
#
# Every manifest list gets a signed build provenance attestation, pushed to
# GHCR next to it: `gh attestation verify oci://<image>:<tag> --owner bulwarkmail`.
on:
release:
types: [published]
@@ -76,6 +82,8 @@ jobs:
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Download digests
@@ -101,7 +109,7 @@ jobs:
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=raw,value=latest,enable=${{ (github.event_name == 'release' && !github.event.release.prerelease) || (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')) }}
type=semver,pattern=v{{version}}
type=semver,pattern={{version}}
type=semver,pattern=v{{major}}.{{minor}}
@@ -116,8 +124,19 @@ jobs:
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
id: inspect
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
digest=$(docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} --format '{{json .Manifest}}' | jq -r .digest)
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- name: Attest build provenance
uses: actions/attest@v4
with:
subject-name: ${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.inspect.outputs.digest }}
push-to-registry: true
create-storage-record: false
build-always:
strategy:
@@ -186,6 +205,8 @@ jobs:
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
@@ -212,7 +233,7 @@ jobs:
flavor: |
suffix=-always,onlatest=true
tags: |
type=raw,value=latest
type=raw,value=latest,enable=${{ (github.event_name == 'release' && !github.event.release.prerelease) || (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')) }}
type=semver,pattern=v{{version}}
type=semver,pattern={{version}}
type=semver,pattern=v{{major}}.{{minor}}
@@ -227,5 +248,16 @@ jobs:
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
id: inspect
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
digest=$(docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} --format '{{json .Manifest}}' | jq -r .digest)
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- name: Attest build provenance
uses: actions/attest@v4
with:
subject-name: ${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.inspect.outputs.digest }}
push-to-registry: true
create-storage-record: false
+21 -3
View File
@@ -29,11 +29,14 @@ jobs:
steps:
- name: Set image name
id: set
env:
REF_NAME: ${{ github.ref_name }}
REPOSITORY: ${{ github.repository }}
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "image_name=ghcr.io/${{ github.repository }}-beta" >> $GITHUB_OUTPUT
if [ "$REF_NAME" = "main" ]; then
echo "image_name=ghcr.io/${REPOSITORY}-beta" >> "$GITHUB_OUTPUT"
else
echo "image_name=ghcr.io/${{ github.repository }}-${{ github.ref_name }}" >> $GITHUB_OUTPUT
echo "image_name=ghcr.io/${REPOSITORY}-${REF_NAME}" >> "$GITHUB_OUTPUT"
fi
build:
@@ -104,6 +107,8 @@ jobs:
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Download digests
@@ -139,5 +144,18 @@ jobs:
$(printf '${{ needs.prepare.outputs.image_name }}@sha256:%s ' *)
- name: Inspect image
id: inspect
run: |
docker buildx imagetools inspect ${{ needs.prepare.outputs.image_name }}:${{ steps.meta.outputs.version }}
digest=$(docker buildx imagetools inspect ${{ needs.prepare.outputs.image_name }}:${{ steps.meta.outputs.version }} --format '{{json .Manifest}}' | jq -r .digest)
echo "digest=$digest" >> "$GITHUB_OUTPUT"
# Signed build provenance, pushed to GHCR next to the image:
# `gh attestation verify oci://<image>:<tag> --owner bulwarkmail`.
- name: Attest build provenance
uses: actions/attest@v4
with:
subject-name: ${{ needs.prepare.outputs.image_name }}
subject-digest: ${{ steps.inspect.outputs.digest }}
push-to-registry: true
create-storage-record: false
+41 -10
View File
@@ -1,12 +1,17 @@
name: Publish Standalone Tarball on Release
# The tarballs build without a write token. The release job, which runs no
# checked-out code, publishes each one with a `.sha256` next to it and a
# signed build provenance attestation:
# `gh attestation verify <tarball> --repo bulwarkmail/webmail`.
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: write
contents: read
jobs:
build:
@@ -52,17 +57,43 @@ jobs:
tar -czf "$TARBALL" bulwark-standalone/
echo "TARBALL=$TARBALL" >> "$GITHUB_ENV"
- name: Upload release asset
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" "$TARBALL" --clobber
- name: Upload artifact (workflow_dispatch)
if: github.event_name == 'workflow_dispatch'
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: standalone-${{ matrix.arch }}
path: ${{ env.TARBALL }}
if-no-files-found: error
retention-days: 7
release:
needs: build
if: github.event_name == 'release'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Download tarballs
uses: actions/download-artifact@v4
with:
pattern: standalone-*
path: tarballs
merge-multiple: true
- name: Checksum tarballs
working-directory: tarballs
run: for tarball in *.tar.gz; do sha256sum "$tarball" > "$tarball.sha256"; done
- name: Attest build provenance
uses: actions/attest@v4
with:
subject-path: tarballs/*.tar.gz
- name: Upload release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" tarballs/*.tar.gz tarballs/*.tar.gz.sha256 --clobber
+40 -13
View File
@@ -7,10 +7,11 @@ name: Build Static Lite
#
# Two targets are built side by side:
# - static: bulwark-lite-<version>.zip for any static web host;
# - stalwart: bulwark-lite-stalwart.zip, a Stalwart `Application` bundle. Its
# release asset name carries no version, so
# releases/latest/download/bulwark-lite-stalwart.zip is a stable
# resourceUrl that follows new releases.
# - stalwart: bulwark-lite-stalwart.zip, a Stalwart `Application` bundle.
# Its LITE-README points `resourceUrl` at the tagged release.
#
# Each zip is published with a `.sha256` next to it and a signed build
# provenance attestation: `gh attestation verify <zip> --repo bulwarkmail/webmail`.
#
# scripts/lite/prepare.mjs deletes the server-only trees in place; that is
# safe here because the Actions checkout is disposable (CI=true is the guard).
@@ -42,7 +43,7 @@ on:
default: false
permissions:
contents: write
contents: read
jobs:
build:
@@ -98,21 +99,47 @@ jobs:
echo "ZIP=${ZIP}" >> "$GITHUB_ENV"
ls -la "${ZIP}"
- name: Upload release asset
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" "$ZIP" --clobber
- name: Upload artifact
if: github.event_name != 'release'
uses: actions/upload-artifact@v4
with:
name: bulwark-lite-${{ matrix.target }}
path: ${{ env.ZIP }}
retention-days: 7
release:
# The only job with a write token, and it runs no checked-out code.
needs: build
if: github.event_name == 'release'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Download zips
uses: actions/download-artifact@v4
with:
pattern: bulwark-lite-*
path: zips
merge-multiple: true
- name: Checksum zips
working-directory: zips
run: for zip in *.zip; do sha256sum "$zip" > "$zip.sha256"; done
- name: Attest build provenance
uses: actions/attest@v4
with:
subject-path: zips/*.zip
- name: Upload release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" zips/*.zip zips/*.zip.sha256 --clobber
smoke:
# Boots the demo export on a plain static server and drives it with
# Playwright: login, open a thread, switch surfaces, reload a deep link.
+1
View File
@@ -16,6 +16,7 @@
# next.js
/.next/
/out/
/lite-image/
# production
/build
+328
View File
@@ -1,5 +1,333 @@
# Changelog
## 1.11.2 (2026-09-26)
1.11.2 fixes the Docker image and the standalone tarballs of 1.11.1, which did not start. It contains all the security fixes from 1.11.1, so please update.
Thank you for your donations:
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
**One-time**
- Anonymous
- [@windsource](https://github.com/windsource)
**Monthly**
- [@jsaathof](https://github.com/jsaathof)
- [@berkersal](https://github.com/berkersal)
- [@NABarnes](https://github.com/NABarnes)
- [@felixzieger](https://github.com/felixzieger)
- [@pr0ton11](https://github.com/pr0ton11)
- [@zeddD1abl0](https://github.com/zeddD1abl0)
- [@fpauser](https://github.com/fpauser)
- [@proxforge](https://github.com/proxforge)
- [@spss20](https://github.com/spss20)
- [@elgringoYan](https://github.com/elgringoYan)
- [@pauladams8](https://github.com/pauladams8)
- [@djpriest](https://github.com/djpriest)
- [@umakers](https://github.com/umakers)
- [@zplizzi](https://github.com/zplizzi)
- [@jeremiah](https://github.com/jeremiah)
- [@Theoretisch1337](https://github.com/Theoretisch1337)
- [@svandive](https://github.com/svandive)
- [@HiltMundell](https://github.com/HiltMundell)
### Fixes
- **Docker**: The server starts again. 1.11.1 excluded `data/` from the standalone build with a pattern that also dropped Next.js's own metadata modules, so the Docker image and the `bulwark-standalone-*` tarballs failed on startup. `npm start` and Bulwark Lite were not affected
## 1.11.1 (2026-09-26)
1.11.1 is a security and bug-fix release. It fixes four reported vulnerabilities, two of them critical, and the findings of a security audit. Please update.
Thank you for your donations:
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
**One-time**
- Anonymous
- [@windsource](https://github.com/windsource)
**Monthly**
- [@jsaathof](https://github.com/jsaathof)
- [@berkersal](https://github.com/berkersal)
- [@NABarnes](https://github.com/NABarnes)
- [@felixzieger](https://github.com/felixzieger)
- [@pr0ton11](https://github.com/pr0ton11)
- [@zeddD1abl0](https://github.com/zeddD1abl0)
- [@fpauser](https://github.com/fpauser)
- [@proxforge](https://github.com/proxforge)
- [@spss20](https://github.com/spss20)
- [@elgringoYan](https://github.com/elgringoYan)
- [@pauladams8](https://github.com/pauladams8)
- [@djpriest](https://github.com/djpriest)
- [@umakers](https://github.com/umakers)
- [@zplizzi](https://github.com/zplizzi)
- [@jeremiah](https://github.com/jeremiah)
- [@Theoretisch1337](https://github.com/Theoretisch1337)
- [@svandive](https://github.com/svandive)
- [@HiltMundell](https://github.com/HiltMundell)
### Security
- **Admin**: Only a Stalwart superuser gets into the admin dashboard. The admin probe accepted Stalwart's default tenant-admin role, so the administrator of a single tenant was signed into a dashboard that configures the whole instance (GHSA-v6hr-cmxm-pv73, thanks @douwezijlstra-frl)
- **Admin**: Admin status is only taken from an admin-configured server. A context cookie minted while custom JMAP endpoints were allowed let a user-chosen server keep answering the admin probe after the switch was turned off (GHSA-j867-89p4-v8hm, thanks @Vip3r-MC)
- **API**: Cross-origin writes are refused on the JMAP passthrough and every other cookie-authenticated `/api/` route, not only `/api/auth/*`. A page on a same-site sibling origin could run arbitrary JMAP as the signed-in user. The gate also decides on the decoded path, so `/api/%61uth/...` no longer skips it (GHSA-9mvj-98f5-9q6g, thanks @kah-ja)
- **Admin**: Admin sign-in attempts are also capped at 50 per 15 minutes across all clients, because a forged `X-Forwarded-For` got a fresh per-IP budget when no reverse proxy is in front (GHSA-7pj2-232x-6698, thanks @richardweinberger)
- **Admin**: Impersonation no longer puts the Stalwart master password into the session cookie. The webmail creates an app password on the target mailbox that expires after 8 hours and is revoked on sign-out, and a used impersonation link is refused after a restart or on another replica
- **Mail**: A sender can no longer fake a DMARC or DKIM pass in the security badges, with a crafted envelope address or an `Authentication-Results` header of their own
- **Mail**: Opening an SVG attachment's thumbnail on its own no longer runs the sender's script in the webmail origin
- **Mail**: Remote content stays blocked in the mobile conversation view, the `.eml` attachment preview, the quoted original of a reply or forward, the print view, and for URL spellings the filter missed (backslashes, CSS escapes, `image-set()`)
- **Mail**: Links in image maps (`<area>`) no longer keep `window.opener`
- **Mail**: A `mailto:` unsubscribe goes to the single address in the link, and the confirmation shows recipient, subject and body before sending
- **Mail**: A crafted `winmail.dat` no longer freezes the tab
- **Composer**: A sender name containing a quote can no longer add a recipient to a draft
- **Filters**: Rule names, header names and sizes are escaped, so rules from plugins or imported filter sets cannot add commands such as `redirect` to the Sieve script
- **Plugins**: Hooks and slots need a declared permission, like the host API does. `http.post` can no longer reach the JMAP passthrough, `/api/admin/*`, `/api/settings` or other credentialed routes, and plugin storage is kept per account and deleted on sign-out
- **Themes**: The theme CSS sanitizer no longer lets remote resources through (`url(//host)`, CSS escapes, `image-set()`, `@font-face` sources), and theme CSS can only target `:root` and `.dark`, also when a plugin transforms it
- **Files**: WOPI file downloads are always served as inert attachments, so a blob typed `text/html` cannot run script in the webmail origin
- **Server**: The SSRF guard also blocks loopback beyond `127.0.0.1`, CGNAT, benchmark, multicast and reserved ranges, and NAT64, 6to4 and Teredo addresses that wrap an internal IPv4 address. Telemetry targets are checked at connect time, and OAuth token and revocation requests never follow redirects
- **Auth**: Synced settings are keyed on the account a bearer token belongs to, so on a multi-domain server `john@b.example` can no longer read the settings of `john@a.example`
- **Auth**: Wrong passwords tried through the login pre-check are limited, so the route can no longer be used as a password oracle or trip Stalwart's ban against the webmail itself
- **Auth**: Signing out ends office editor sessions opened in that browser, and a full sign-out clears search history, open tabs, Files recents, staged plugin uploads and the other accounts' leftovers, also in other open tabs
- **Auth**: Signing out when another account's restore had failed no longer leaves that account resumable
- **Auth**: The failed TOTP login no longer passes on a user-chosen server's error body
- **Server**: Visitors who are not signed in no longer get detailed health data, the pending advisory text, the plugin list, the full admin policy or every server's domain list
- **Server**: The setup token is passed in the URL fragment, so it stays out of access logs, referers and history
- **Server**: Not-found pages outside the app tree cannot be framed, `ALLOWED_FRAME_ANCESTORS` never applies to the admin dashboard or setup, and sibling subdomains can no longer widen the sidebar-app `frame-src`
- **Server**: Favicons are only served as raster images
- **Docker**: Runtime state and secrets (`data/`, `local-data/`, `.env`) stay out of the standalone build and the image, and the mock JMAP server can no longer be switched on in a release build
- **Lite**: Sign-out revokes the refresh token, and a failed token login no longer keeps the password in `sessionStorage`
- **Lite**: Every static page gets a CSP that allows only its own inline scripts, the Stalwart bundle refuses to run inside a foreign frame, and the Stalwart install instructions use a tagged release with a published `.sha256` instead of `releases/latest`
### Features
- **Calendar**: Tasks with a due date show in the month view (#1107)
### Changes
- **Plugins**: Hooks that change outgoing mail need `email:send`, and display takeovers need `email:render-takeover`. Plugins that don't declare them lose those hooks
- **Themes**: Theme CSS keeps only `:root` and `.dark` rules plus `@font-face`, `@keyframes`, `@media` and `@supports`. Every `url()` except a `#fragment` is removed
- **Admin**: Impersonated sessions end after 8 hours, and sessions minted by earlier versions are signed out
- **Server**: `/api/health?detailed=true` needs a session
### Fixes
- **Mail**: The inbox keeps rendering when a message has an unparsable date (#1099)
- **Mail**: A folder no longer switches back to the unified inbox while it loads (#1102, thanks @guisea)
- **Mail**: Replies in the unified inbox come from the identity of the account that received the mail (#1104)
- **Mail**: Mail deleted during a list refresh no longer reappears (#966)
- **Mail**: The message list no longer jumps while attachment chips load
- **Mail**: Scrolling the unified inbox, cross-account views, tag views and "All folders" search no longer skips messages
- **Mail**: A failed message-list read keeps the list on screen instead of showing an empty folder
- **Mail**: Marking read, starring, tagging, emptying a folder and cancelling a scheduled send report it when the server refuses them
- **Mail**: Tagging and pinning write only the keywords that change, so they no longer mark mail unread that was read on another device
- **Mail**: New mail and folder changes keep arriving for users with seven or more shared accounts
- **Send**: A dropped connection can no longer send a message twice
- **Send**: Undo and edit of a message sent from a group identity act in the group's own account
- **Composer**: A reply draft stays in its thread when it is re-opened or undone
- **Composer**: An open draft stays on its own account across an account switch
- **Accounts**: Quick account switches no longer mix up identities, so replies go out with the right From address
- **Accounts**: Folders, filters and the account security page no longer show the previous account's data after a switch
- **Accounts**: Settings sync turns back on after signing out of one of several accounts
- **Push**: A notification opens its message in the account it came from
- **Calendar**: Events you declined show struck through (#1110)
- **Calendar**: Daily recurring events keep going past a DST gap
- **Calendar**: iCal subscriptions stay with the login that created them, so refreshing or removing one no longer touches a calendar of another login
- **Auth**: SSO sign-in behind nginx no longer fails with a 502 when the cookies would overflow its header buffer (#1096)
- **Auth**: Sign-in and addresses work on internationalized domains (#1100)
- **Lite**: A rate-limited token endpoint no longer signs you out
- **i18n**: The dark/light mode titles and the "Themes" settings tab are translated in every language (#1105, #1106, thanks @dulinux), and so is the themes settings panel
- **i18n**: Updated Portuguese (Brazil) translation (#1106, thanks @dulinux)
## 1.11.0 (2026-09-23)
1.11.0 introduces **Bulwark Lite**, a static build of the webmail that runs without a Node server, and fixes many places where Bulwark and Stalwart disagreed about mail, filters, calendars, contacts and files. It contains everything from the three 1.11.0 betas. The `latest` Docker tag moves to this release.
Thank you for your donations:
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
**One-time**
- [@windsource](https://github.com/windsource)
**Monthly**
- [@jsaathof](https://github.com/jsaathof)
- [@berkersal](https://github.com/berkersal)
- [@NABarnes](https://github.com/NABarnes)
- [@felixzieger](https://github.com/felixzieger)
- [@pr0ton11](https://github.com/pr0ton11)
- [@zeddD1abl0](https://github.com/zeddD1abl0)
- [@fpauser](https://github.com/fpauser)
- [@proxforge](https://github.com/proxforge)
- [@spss20](https://github.com/spss20)
- [@elgringoYan](https://github.com/elgringoYan)
- [@pauladams8](https://github.com/pauladams8)
- [@djpriest](https://github.com/djpriest)
- [@umakers](https://github.com/umakers)
- [@zplizzi](https://github.com/zplizzi)
- [@jeremiah](https://github.com/jeremiah)
- [@Theoretisch1337](https://github.com/Theoretisch1337)
- [@svandive](https://github.com/svandive)
- [@HiltMundell](https://github.com/HiltMundell)
### Features
- **Lite**: Bulwark Lite, a static build that talks to the JMAP server straight from the browser. `npm run build:lite` builds it, and every release ships `bulwark-lite-<version>.zip` for any static web host
- **Lite**: `bulwark-lite-stalwart.zip`, a Stalwart Application bundle that picks up its mount prefix at runtime, with OpenID Connect login
- **Lite**: Container image `ghcr.io/bulwarkmail/webmail-lite` (#1081)
- **Themes**: "Flat fields" theme
- **Calendar**: Attendees can answer a single occurrence of a recurring event (#1086)
- **Mail**: Office attachments open read-only in the document editor, straight from the message, when a WOPI editor is configured (#1047)
- **Push**: Optional inbox-only push notifications (#983, thanks @guisea)
- **Mail**: Toasts for mail actions that gave no feedback, and an "email sent" toast after immediate sends
- **Filters**: "Keep a copy" option for forward actions
- **Filters**: Per-rule "Also move messages marked as spam" option
- **Admin**: Login page toggles in admin settings for the version, the 2FA code option, the heading and the subtitle (#1068)
- **Navigation**: Deep links resolve to the local instance
### Changes
- **Auth**: Signing out of an SSO account also signs out of the identity provider when it advertises an `end_session_endpoint` (#905). Set `OAUTH_END_SESSION=false` to keep the provider session, for example when other apps share it, and `OAUTH_POST_LOGOUT_REDIRECT_URI` to send users back to the webmail afterwards
- **Filters**: Move and copy rules leave mail that Stalwart marked as spam in Junk. Before, a rule such as "subject contains invoice" also collected phishing. "Keep" rules stay unguarded as the allow-list
- **Mail**: Search sends terms exactly as typed, without adding a prefix wildcard, in tag views too
- **Mail**: The message list loads attachment chips lazily, so large folders open faster (#1089)
- **Send**: Scheduled send is limited to 7 days, the most Stalwart accepts
- **UI**: Icon set migrated from Lucide to Tabler Icons
- **Docker**: Pre-releases no longer move the `latest` tags
- **Docs**: Most README details moved to the website docs, with new screenshots
### Fixes
- **Mail**: "All folders" search includes shared accounts (#1082)
- **Mail**: A search inside a tag view narrows the tag instead of replacing it (#1084, thanks @rotterp)
- **Mail**: Leaving an account drops a search folder scope that belongs to it (#1084, thanks @rotterp)
- **Mail**: A failed search shows an error instead of "No results found"
- **Mail**: In unified views, threads belong to the account that owns them (#1012, thanks @lucamzanon)
- **Mail**: A failed move between accounts no longer deletes the original
- **Mail**: "Mark all as read" marks every unread message instead of skipping pages past the first 500
- **Mail**: Mark as spam and not spam report moves the server refuses
- **Mail**: Unread-first order keeps bringing up unread mail past the first page
- **Mail**: The tag button in a row's hover actions opens the tag picker instead of clearing the message's tags (#1032, thanks @douwezijlstra-frl)
- **Mail**: Tag and tab counters no longer download every matching id
- **Mail**: Text in fixed-width tables wraps to the screen on iOS (#1020)
- **Mail**: Mail action toasts show again
- **Send**: A send the server refuses is reported as failed instead of sent. When only filing the sent copy fails, a warning says so, so the mail is not sent twice
- **Send**: A From override is also used as the envelope sender where the server accepts it. Where it doesn't, as on Stalwart, the composer says that the identity's address shows in the Return-Path (#1009)
- **Send**: Sending with an identity adds its Bcc addresses
- **Composer**: Attachments over the server's size limits are refused before upload
- **Composer**: A staged attachment is kept until its upload finishes
- **Filters**: Filters keep running while the auto-reply is on, and saving a filter no longer turns the auto-reply off
- **Filters**: "Mark read", "star" and "add label" reach mail that the same rule moves, and rules keep their target folder after it is renamed
- **Filters**: Forward actions respect the server's redirect limit (one on Stalwart) instead of being dropped without notice
- **Auto-reply**: A warning appears before saving an auto-reply that Stalwart would refuse as too long
- **Calendar**: Edits to a single occurrence stay on that occurrence and keep its details
- **Calendar**: Date ranges are queried in the right time zone, and long recurring series or more than 1000 events no longer leave the calendar incomplete
- **Calendar**: When the server refuses an event's invitations, you can save the event without sending them
- **Calendar**: Subscriptions that fail with "Not authenticated" name the cause, such as a mail server certificate the webmail server does not trust (#1073)
- **Calendar**: The subscription dialog no longer promises CalDAV URLs
- **Calendar**: The import dropdown opens above the day and week views (#1049)
- **Contacts**: Contacts with calendar, scheduling or free/busy links save, and cleared fields are cleared on the server
- **Contacts**: vCard import no longer sends fields Stalwart rejects and writes addresses in the RFC 9553 form
- **Contacts**: Deleting an address book that still holds contacts works
- **Files**: Every file is listed, even when the account has more than `maxObjectsInGet` (#1069)
- **Files**: Copying a folder copies its contents, and changes inside a shared drive go to the drive's account
- **Files**: An upload or new folder whose name is taken becomes "name (2)" instead of failing
- **Files**: Sharing works on Stalwart versions before 0.16.6
- **Files**: Names Stalwart refuses are caught before sending, uploads store an accepted variant, and Office files keep their MIME type on Stalwart 0.16.6 and later
- **Account**: You stay signed in after changing your password in settings
- **Account**: Users who are not admins see their name, and changing the password or turning TOTP off asks for the current code
- **Auth**: OAuth endpoints on the configured issuer's own host are accepted when they resolve to a private address, so split-DNS setups no longer need `OAUTH_ALLOW_PRIVATE_ENDPOINTS` (#1028)
- **Auth**: Linking the mobile app re-authenticates against the account's own identity provider when OAuth is configured per server
- **Push**: Push subscriptions are renewed before Stalwart's 7-day expiry, so a tab or app left open for over a week keeps getting notifications
- **Sharing**: Principals are listed in directories with more than 500 users
- **Lite**: The login page hides the server field once `config.json` sets `jmapServerUrl` (#1087)
- **Plugins**: The plugin sandbox follows the app's "Automatic" language (#976, thanks @bartfaizoli76)
- **UI**: The global error page loads the app's styles
## 1.11.0-beta.3 (2026-09-23) - Pre-release
General test release of everything planned for 1.11.0 so far. Not recommended for production; the `latest` Docker tag stays on 1.10.0.
### Added
- Attendees can answer a single occurrence of a recurring event (#1086).
- Optional inbox-only push notifications (#983, thanks [@guisea](https://github.com/guisea)).
- OpenID Connect login in the Bulwark Lite bundle for Stalwart.
- Bulwark Lite as a container image (#1081).
### Changed
- The message list loads attachment chips lazily, so large folders open faster (#1089).
- Mail search sends search terms exactly as typed, without adding a prefix wildcard. This also applies in tag views.
- Scheduled send is limited to 7 days, the most Stalwart accepts.
### Fixed
- "All folders" search includes shared accounts (#1082).
- A search inside a tag view narrows the tag instead of replacing it.
- Leaving an account drops a search folder scope that belongs to it (#1084, thanks [@rotterp](https://github.com/rotterp)).
- In unified views, threads belong to the account that owns them (#1012, thanks [@lucamzanon](https://github.com/lucamzanon)).
- A failed move between accounts no longer loses the message.
- "Mark all as read" marks every unread message and reports spam moves the server refuses.
- Send and search report failed JMAP calls instead of claiming success.
- Attachments over the server's size limits are refused before upload.
- Sending with an identity adds its Bcc addresses.
- Tag and tab counters no longer download every matching id.
- Mail filters keep running while the auto-reply is on, keep flags and folder targets when mail is moved, and no longer pull spam out of Junk.
- A warning appears before you save an auto-reply that Stalwart would refuse as too long.
- Edits to a single occurrence stay on that occurrence and keep its details.
- Calendar ranges are queried in the right time zone and past the server limits.
- If the server refuses an event's invitations, you are offered to save the event anyway.
- The calendar subscription dialog no longer promises CalDAV URLs.
- Contacts with calendar links or cleared fields save correctly.
- Files lists every file, even when the account has more than `maxObjectsInGet` (#1069).
- Files can copy whole folders and write to shared drives, shares files on Stalwart versions before 0.16.6, and handles file names and types Stalwart refuses.
- You stay signed in after changing your password in settings.
- Users who are not admins see their name and can change a TOTP password.
- Push subscriptions are renewed before Stalwart's 7-day expiry.
- Sharing lists principals in directories with more than 500 users.
- The plugin sandbox follows the app's "Automatic" language (#976, thanks [@bartfaizoli76](https://github.com/bartfaizoli76)).
- The global error page loads the app's styles.
## 1.11.0-beta.2 (2026-09-21) - Pre-release
Second test release for **Bulwark Lite**. Not recommended for production; the `latest` Docker tag stays on 1.10.0.
### Added
- Toasts for mail actions that had no feedback, plus an "email sent" toast after immediate sends.
- Deep links resolve to the local instance.
### Fixed
- A staged attachment is kept until its upload finishes.
- Toasts show again.
## 1.11.0-beta.1 (2026-09-19) - Pre-release
Test release for **Bulwark Lite**, the static build of the webmail. Not recommended for production; the `latest` Docker tag stays on 1.10.0.
### Added
- **Bulwark Lite static export**: `npm run build:lite` produces a server-less build that talks to the JMAP server directly from the browser. The release ships `bulwark-lite-<version>.zip` for any static web host.
- **Bulwark Lite for Stalwart Applications**: `bulwark-lite-stalwart.zip` is a Stalwart `Application` bundle that picks up its mount prefix at runtime.
- **"Flat fields" theme**.
### Changed
- Icon set migrated from Lucide to Tabler Icons.
### Fixed
- Hardened Bulwark Lite login, deep-link replay and settings gating.
## 1.10.0 (2026-09-17)
Thank you for your donations:
+13 -246
View File
@@ -1,256 +1,23 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture>
</div>
# Contributing to Bulwark Webmail
We're writing the webmail we wanted in 2026 and didn't find: a JMAP-native client with an interface built this decade. It's AGPL and self-hosted, run by the people who use it rather than sold to them.
Bug reports, feature requests, translations and patches are all welcome.
If that sounds like your kind of project, we'd love the help.
The full guide is in the documentation under [Contributing](https://bulwarkmail.org/docs/development/contributing). It covers the development setup, the test suites, translations and right-to-left layouts, code style, and how pull requests are reviewed.
## Join the community
You don't need to be an expert to contribute. A dev environment that won't start, a bug you're not sure how to report, a translation you're stuck on: Discord is the fastest way to get unstuck and to meet the people working on this.
- **Get support** - real-time help with development hurdles
- **Share ideas** - feature suggestions, design feedback, doc improvements
- **Collaborate** - meet the team and other contributors
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
---
## Getting started
### Development setup
1. **Fork and clone** the repository:
```bash
git clone https://github.com/bulwarkmail/webmail.git
cd webmail
```
2. **Install dependencies**:
```bash
npm install
```
3. **Set up environment**:
```bash
cp .env.dev.example .env.local
```
This enables the built-in mock JMAP server (`DEV_MOCK_JMAP=true`), so you can
develop without a mail server. Log in with any username and password. To work
against a real server instead, copy `.env.example` and set `JMAP_SERVER_URL`.
4. **Start development server**:
```bash
npm run dev
```
Then open http://localhost:3000.
### Code quality
Before submitting a pull request, ensure your code passes all checks:
To get a development server running:
```bash
# Type checking
npm run typecheck
# Linting
npm run lint
# Fix lint issues automatically
npm run lint:fix
git clone https://github.com/bulwarkmail/webmail.git
cd webmail
npm install
cp .env.dev.example .env.local # built-in mock JMAP server, no mail server needed
npm run dev
```
These checks run automatically on commit via Husky pre-commit hooks.
Before you open a pull request, run `npm run typecheck && npm run lint && npx vitest run`. Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:` and so on).
### Testing
You can use AI tools for your contributions. The guide's [AI-assisted contributions](https://bulwarkmail.org/docs/development/contributing#ai-assisted-contributions) section explains what we expect.
| Suite | Command | What it covers |
| ---------------- | -------------------------- | ------------------------------------------------------------------ |
| **Unit** | `npx vitest run` | Vitest + jsdom. Tests live in `__tests__/` folders next to the code |
| **Translations** | `npm run test:translations` | Locale files checked for structural drift against English |
| **Integration** | `npm run test:integration` | Playwright against a real Stalwart server in Docker |
| **E2E smoke** | `npx playwright test` | UI smoke tests against `npm run dev` |
Run a single unit test file with `npx vitest run lib/__tests__/<name>.test.ts`, or `npx vitest` to watch.
The integration suite needs Docker and takes several minutes; it has its own setup notes and findings log in [integration/README.md](integration/README.md). New behavior that touches mail/folder synchronization or multi-account handling belongs there.
## Code style guidelines
### TypeScript
- Use TypeScript for all new code
- Define proper types and interfaces
- Avoid `any` types when possible
- Use meaningful variable and function names
### React components
- Use functional components with hooks
- Keep components focused and single-purpose
- Extract reusable logic into custom hooks
- Place components in appropriate directories under `/components`
### Styling
- Use Tailwind CSS utility classes
- Follow the existing design system
- Support both dark and light themes
- Use CSS variables for theme colors
## Internationalization (i18n)
This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 23 additional locales (ar, ca, cs, da, de, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl, pt, ro, ru, sk, tr, uk, zh).
Arabic, Hebrew, and Persian render right-to-left (see `i18n/direction.ts`). Use Tailwind's **logical** utilities (`ms-*`/`me-*`, `ps-*`/`pe-*`, `start-*`/`end-*`) rather than physical ones (`ml-*`, `pl-*`, `left-*`) so layouts flip correctly. For popovers positioned in JS via `getBoundingClientRect()`, check `isDocumentRTL()`: inline `position: fixed` styles don't pick up logical utilities.
### Rules
1. **Never hardcode user-facing text** - always use translations:
```tsx
const t = useTranslations("namespace");
return <div>{t("key")}</div>;
```
2. **Add new keys to `en/common.json` first.** Other locales can follow in the same PR or a follow-up - missing keys fall back to English.
3. **Namespace organization**:
- `login.*` - login page
- `sidebar.*` - sidebar navigation
- `email_list.*` - email list
- `email_viewer.*` - email viewer
- `email_composer.*` - composer
- `settings.*` - settings page
- `notifications.*` - toasts and alerts
- `common.*` - shared strings
4. **Locale-aware navigation**:
```tsx
router.push(`/${params.locale}/settings`);
```
### Adding a new locale
Registering a new locale takes edits in four places:
1. `locales/<code>/common.json` - copy `locales/en/common.json` and translate
2. `i18n/routing.ts` - add the code to `SUPPORTED_LOCALES`
3. `i18n/request.ts` - add a `case` to the static-import switch
4. `components/ui/language-switcher.tsx` - add `{ value, label }` with the **native** language name, plus a flag in `components/ui/flag-icons.tsx`
For a right-to-left language, also add the code to `rtlLocales` in `i18n/direction.ts`.
Run `npm run test:translations` afterwards - it checks the locale files for structural drift against English.
## Pull request process
### Before submitting
1. **Create a feature branch**:
```bash
git checkout -b feature/your-feature-name
```
2. **Make your changes** following the code style guidelines
3. **Test your changes** thoroughly, and add unit tests for new logic
4. **Update translations** if you added user-facing text
5. **Run all checks**:
```bash
npm run typecheck && npm run lint && npx vitest run
```
### Submitting
1. **Push your branch** to your fork
2. **Open a Pull Request** with:
- Clear title describing the change
- Description of what was changed and why
- Screenshots for UI changes
- Reference to any related issues
### Commit message convention
Follow the conventional commits format:
- `feat:` - New features
- `fix:` - Bug fixes
- `docs:` - Documentation changes
- `style:` - Code style changes (formatting, etc.)
- `refactor:` - Code refactoring
- `test:` - Adding or updating tests
- `chore:` - Maintenance tasks
Examples:
```
feat: add email threading support
fix: resolve attachment download issue
docs: update README with keyboard shortcuts
```
## Project structure
```
webmail/
├── app/ # Next.js App Router
│ ├── (main)/[locale]/ # Locale-aware app pages (mail, calendar, contacts, files, settings)
│ ├── (main)/admin/ # Admin dashboard
│ ├── (main)/setup/ # First-launch setup wizard
│ ├── (sandbox)/ # Isolated plugin sandbox routes
│ └── api/ # Route handlers (auth, admin, jmap, caldav, …)
├── components/ # React components
│ ├── email/ # Email list, viewer, composer
│ ├── calendar/ contacts/ files/ filters/ templates/
│ ├── layout/ # Sidebar, shell, navigation
│ ├── settings/ # Settings panels
│ ├── plugins/ # Plugin host UI
│ └── ui/ # Reusable primitives
├── contexts/ # React contexts
├── hooks/ # Custom React hooks
├── i18n/ # next-intl routing, locale detection, RTL direction
├── lib/ # Utilities and libraries
│ ├── jmap/ # JMAP client implementation
│ ├── stalwart/ # Stalwart-specific admin/API helpers
│ ├── admin/ auth/ oauth/ # Config, sessions, OAuth flows
│ ├── plugin-sandbox/ # Plugin sandbox bridge and hardening
│ └── __tests__/ # Vitest unit tests
├── locales/ # Translation files, one directory per locale
├── stores/ # Zustand state stores
├── public/ # Static assets and branding
├── e2e/ # Playwright smoke tests (against `npm run dev`)
└── integration/ # Dockerized Stalwart + Playwright suite
```
## Security
- **Never commit secrets** - API keys, passwords, tokens, `.env*` files
- **Sanitize user input** and email content
- **Block external content** by default - privacy is the point
- **Report vulnerabilities privately** to dev@bulwarkmail.org, not via public issues
## Questions?
Open an issue, search existing ones, or ask in Discord. Thanks for helping build the webmail we all wished existed.
- Questions: ask on [Discord](https://discord.gg/tYCujymGrT).
- Documentation fixes: the docs are Markdown files in the [website repository](https://github.com/bulwarkmail/website/tree/main/docs).
- Security vulnerabilities: report them privately to [dev@bulwarkmail.org](mailto:dev@bulwarkmail.org) or through a [security advisory](https://github.com/bulwarkmail/webmail/security/advisories/new), never in a public issue.
+10 -1
View File
@@ -21,7 +21,16 @@ ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
# `git rev-parse` inside the build can't find it - CI must pass it in.
ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT
RUN npx next build --webpack
# The mock JMAP server (DEV_MOCK_JMAP) accepts any password and is for
# `npm run dev` only; images ship without it.
# `next build` type-checks every .ts file, and some unit tests import the
# deleted route, so the tests go too. .dockerignore already drops them from a
# local context, but a remote Git context never applies .dockerignore (#1118).
RUN rm -rf "app/api/dev-jmap" && \
find . -path ./node_modules -prune -o \
\( -name __tests__ -o -name "*.test.ts" -o -name "*.test.tsx" \) \
-prune -exec rm -rf {} \; && \
npx next build --webpack
FROM node:24-alpine AS runner
+57
View File
@@ -0,0 +1,57 @@
# Bulwark Lite as a container: the static export behind an unprivileged nginx,
# no Node.js at runtime. Mount your own config.json (and policy.json) over the
# ones in /usr/share/nginx/html to configure it - see README.md, "Static Lite
# build".
#
# docker build -f Dockerfile.lite -t bulwark-lite .
# The export is plain files, so it is built once on the build host's
# architecture, whatever the target platform is.
FROM --platform=$BUILDPLATFORM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# scripts/lite/prepare.mjs deletes the server-only trees in place; this layer
# is disposable, which is what CI=true asserts.
ENV CI=true
# Defaults baked into config.json; every one of them can still be changed by
# mounting a config.json. No NEXT_PUBLIC_BASE_PATH: the image serves from /.
ARG LITE_JMAP_SERVER_URL=
ARG LITE_APP_NAME=
ARG LITE_ALLOW_CUSTOM_ENDPOINT=
ARG LITE_REMEMBER_ME=
ARG LITE_DEMO_MODE=
# Comma-separated locale subset, e.g. en,de (default: all).
ARG LITE_LOCALES=
ARG NEXT_PUBLIC_DEFAULT_LOCALE=
# Commit SHA shown in the About screen. .dockerignore excludes .git, so CI
# must pass it in.
ARG GIT_COMMIT=unknown
RUN LITE_TARGET=static NEXT_PUBLIC_BASE_PATH= npm run build:lite && \
node scripts/lite/container.mjs /image
FROM nginxinc/nginx-unprivileged:1.30-alpine-slim
LABEL org.opencontainers.image.title="Bulwark Webmail Lite"
LABEL org.opencontainers.image.description="Bulwark Webmail as static files behind nginx; talks to the JMAP server straight from the browser"
LABEL org.opencontainers.image.source="https://github.com/bulwarkmail/webmail"
LABEL org.opencontainers.image.url="https://github.com/bulwarkmail/webmail"
LABEL org.opencontainers.image.licenses="AGPL-3.0-only"
LABEL org.opencontainers.image.vendor="rbm.systems"
# connect-src of the Content-Security-Policy. Set it to your JMAP server's
# origin (e.g. https://mail.example.com) once config.json pins the server.
ENV LITE_CSP_CONNECT_SRC="*"
# Only LITE_* is substituted into the nginx template, never nginx's own $uri etc.
ENV NGINX_ENVSUBST_FILTER="^LITE_"
# COPY only: no command runs in this stage, so other platforms build without emulation.
COPY --from=builder --chmod=0755 /app/scripts/lite/container/15-lite-listen-ipv6.envsh /docker-entrypoint.d/
COPY --from=builder /image/default.conf.template /etc/nginx/templates/default.conf.template
COPY --from=builder /image/html/ /usr/share/nginx/html/
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/lite-build.json || exit 1
-163
View File
@@ -1,163 +0,0 @@
# Features
## Mail
- Read, compose, reply, reply-all, and forward in a Tiptap rich-text editor that handles inline images, drag-and-drop embedding, and tables
- Gmail-style threading, expanded inline, with a conversation toggle you can switch off
- The Unified Mailbox combines Inbox, Sent, Drafts, Junk, Archive, and Trash. By default it stays inside the active account and its shared/group folders; an admin can unlock a cross-account mode that spans every connected account. The combined folder rows only appear once 2+ accounts are connected (or "Include group inboxes" is on and a group inbox exists) — with just one account and no group inbox, turning the setting on adds the section header but nothing under it.
- All mail, Unread, and Starred obey that same account boundary and can be narrowed to a per-account folder selection. Every row names the folder its message came from. Each of these three is a separate admin policy gate (see [Admin & extensibility](#admin--extensibility)) — if none are enabled for your instance, the toggles for them do not even appear in Settings → Appearance, and the Unified Mailbox section can look empty even with the feature turned on and multiple accounts connected. Ask your admin to enable the ones you need.
- Search runs across all unified views; the per-role mailboxes add the full filter panel on top
- Three mail layouts: split three-pane, focused list, or reading pane at the bottom
- Drafts auto-save, keeping the chosen identity, the HTML body, and correct `In-Reply-To` / `References` headers on replies
- Attachments upload, download, drag out to the file system, and preview inline. Images and PDFs render on desktop and mobile, composer attachments open on click, and `.eml` (`message/rfc822`) parts display as a nested email. There are list thumbnails, and a warning when you mention an attachment and forget it.
- Scheduled send, plus a configurable delay before anything leaves the outbox
- Sending without a subject asks for confirmation instead of refusing, and the prompt can be switched off
- Read receipts (MDN, RFC 8098)
- Quoted text lands in an editable island that keeps the original layout
- Full-text search with a JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
- Multi-select for batch archive, delete, move, and tag
- Archive directly, by year, or by month
- Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them
- Tags optionally nest: pick a parent when you create one and the sidebar turns them into a tree
- Each tag can be configured to show always, only when there are unread mails or always be hidden
- Tag names and colors live in the browser, while the tags themselves ride on your messages as JMAP keywords. A scan finds keywords no local tag explains and adds them back.
- Star or unstar, with a configurable mark-as-read delay
- Configurable message-list order: presets (unread first, starred first, tagged first) or up to three custom sort levels, applied server-side through the JMAP sort so the whole folder is ordered, for the Inbox only or every folder
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
- A refresh button in the list toolbar that spins while the fetch is in flight, alongside the F5 / Ctrl+R / pull-to-refresh gestures it shares a code path with
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
- Plain-text composer mode and Reply-To
- The signature sits above or below the quoted text, per identity
- Override the From header in the composer. Reply to an alias on a domain you own and it auto-fills as the sender, even when no identity exists for it.
- Import `.eml` files from the folder right-click menu
- Forward a message as an `.eml` attachment, from the viewer or the list right-click menu, named by your filename template
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
- Folders take an icon, nest, and show counts in the sidebar
- Print from the viewer
- Browser back and forward move through mail history
## Calendar
- Month, week, day, and agenda views, with a mini-calendar and task list in the sidebar
- Drag an event to reschedule it, click-drag to create one, pull an edge to resize. Everything snaps to 15 minutes.
- Recurring events edit and delete by scope: this occurrence, this and following, or all
- iMIP invitations on create and update (RFC 5545 / 6047), an organizer/attendee panel, and RSVP with trust assessment
- `.ics` attachments are detected in the email viewer, so you can RSVP or import without leaving the message
- iCalendar import previews first, then bulk-creates, deduplicating on UID
- iCal / webcal subscriptions, editable, with batch import
- A birthday calendar generated from your contacts
- Virtual locations (video-conference URLs) are first-class event fields
- Tasks with due dates, priority, and completion status
- Shared calendars through CalDAV discovery, resolving homes across accounts, colored per viewer
- Week numbers, hover preview, notifications with a sound picker
- JMAP push keeps everything in sync
## Contacts
- JMAP sync (RFC 9553 / 9610), falling back to local storage
- Several address books, with drag-and-drop between them
- Groups with member management
- A card can be a person or an organization. Organization cards need no personal name; the org name carries the card and fills vCard's mandatory `FN`.
- vCard import/export (RFC 6350) that flags duplicates
- Trusted senders live in their own JMAP address book
- Autocomplete on To, Cc, and Bcc
## Filters & templates
- Server-side filters as JMAP Sieve Scripts (RFC 9661)
- A visual rule builder: conditions on From, To, Subject, Size, Body, Attachment and more, each matching multiple values, with actions to move, forward, star, or discard
- Rules written in other clients survive the round-trip
- Raw Sieve editor with syntax validation
- A vacation responder you can schedule to a date range
- Templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
## Files
- Browse Stalwart's native JMAP FileNode storage as a real folder tree. Legacy flat-named files migrate into nested `FileNode` folders on first load.
- Streamed WebDAV PUT upload, whole folders included, with progress
- Upload limits follow the server's own configuration
- Grid or list, sorted by name, size, or date
- Preview images, text, audio, and video
- Cut, copy, paste, duplicate; favorites; recent files
- JMAP sharing (RFC 9670) for files and folders. Pick a user or group from the principal picker and grant read, read/write, or manager. Shared items get an indicator, and anything other principals share with you appears under "Shared with me".
## Security & privacy
- External content stays blocked until you say otherwise, and trusted senders are remembered
- HTML sanitized through DOMPurify
- S/MIME: manage certificates, then sign, encrypt, decrypt, and verify. Legacy 3DES / PBE is supported, and keys stay isolated per account.
- Add S/MIME or PGP public keys per account, then turn on Stalwart's encryption at rest against one of them. The algorithm, whether new mail is encrypted on upload, and whether spam training runs before encryption are all configurable.
- SPF / DKIM / DMARC indicators surface the most severe SPF result and drop the "via" badge on spoofed mail
- OAuth2 / OIDC with PKCE against Keycloak, Authentik, or the built-in provider, plus OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
- TOTP two-factor authentication
- Password and 2FA management through the Stalwart admin API
- "Remember me" is optional and rides an AES-256-GCM encrypted httpOnly cookie
- CSP is enforced with a per-request nonce, alongside SSRF redirect validation, a sandboxed PDF iframe, and IP spoofing prevention
- Plugins are scanned for dangerous patterns and need admin approval
- Newsletter unsubscribe (RFC 2369)
## Interface
- Split three-pane, focused list, or bottom reading pane, columns resizable
- Dark and light themes. Email colors are remapped by luminance, so a mail hard-coded to dark-on-white stays readable on a dark background.
- Bundled themes such as Aurora Glass and Elastic. Each theme card renders as a miniature mailbox built from that theme's own colors, with chips for the light and dark variants.
- Layouts for desktop, tablet, and mobile
- Full keyboard navigation
- Drag and drop to organize mail and assign tags
- A guided tour for first-time users
- Right-click menus, and toasts that offer an undo
- Every screen has a permalink. `/mail/message/<id>`, `/mail/thread/<id>`, `/mail/folder/inbox`, `/calendar/day/2026-08-06`, `/calendar/event/<id>`, `/contacts/<id>`, `/files/<folder>`, and `/settings/<tab>` all open directly, the address bar follows what you are looking at, and "Copy link" is in the message, conversation, and event menus. Dashboards and launchers can link straight to a message; browser back and forward walk the mail UI.
- Toolbar position, favicon, and login branding are configurable
- Sidebar apps pin and reorder by drag
- Settings sync between devices, encrypted
- Storage quota display
- WCAG AA contrast, reduced-motion support, focus traps, and screen-reader live regions
## Internationalization
27 languages: Català · Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Norsk bokmål · Polski · Português · Română · Slovenčina · Türkçe · Монгол · Русский · Українська · עברית · العربية · فارسی · 한국어 · 日本語 · 简体中文 · 繁體中文
- Arabic, Hebrew, and Persian render right-to-left; document direction and logical layout flip automatically
- The browser's `Accept-Language` picks the first language, and the choice persists per user
- `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback, `NEXT_PUBLIC_LOCALE_PREFIX` the URL prefix
## Identity & multi-account
- Run several accounts at once and switch instantly, each keeping its own session. The 5-account cap lifts on HTTP/2 servers; on HTTP/1.1, browser connection pooling still sets the limit.
- An account switcher showing connection status, and a default account
- Multiple sender identities, each with its own signature, synced automatically and badged in the viewer and list
- Signature above or below the quoted text
- Sub-addressing (`user+tag@domain.com`), delimiter configurable, with tag suggestions drawn from context
- Shared folders across accounts
- Shared and group (delegated) accounts put their folders next to your own, and "Include group inboxes" merges them into the Unified Mailbox. You can open, mark read, flag as spam or not-spam, move, delete, and archive their messages from there, and folder unread counts stay in step.
- Several JMAP servers per deployment, optionally auto-picked by email domain
- Custom JMAP endpoints on the login form, when `ALLOW_CUSTOM_JMAP_ENDPOINT` permits it
## Admin & extensibility
- A setup wizard runs on first launch and walks through JMAP servers, OAuth/OIDC, the session secret, logging, branding (uploads included), and the admin password. It writes to the admin config dir, so `.env.local` stays untouched.
- The Stalwart admin dashboard, its policy sections collapsed into one tabbed page
- Admin policy gates for the Unified Mailbox: turn All mail / Unread / Starred on or off org-wide, and gate cross-account capability separately (off by default, auto-enabled on upgrade for instances already using it). A gated view still respects the user's own toggle.
- Admin storage splits in two. `ADMIN_CONFIG_DIR` is operator-authored and can be mounted read-only once setup finishes; `ADMIN_STATE_DIR` holds the runtime audit log and login timestamps.
- JSON config can read secrets from files (`passwordHashFile`, `sessionSecretFile`, `oauthClientSecretFile`) for Docker and Kubernetes secret mounts
- An admin toggle controls search-engine indexing (`robots.txt` / `noindex`)
- Plugin system: a schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar, email-banner, calendar-event, and contact crypto-key slots, i18n APIs (sandboxed plugins localize through manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement
- Plugin APIs reach contacts (`contact.get` / `create` / `update` / `search`), the signed-in user (`user.getAccounts`, `user.getIdentities`, `user.logout` and a logout hook), and — on the privileged tier — public keys and encryption at rest. Each sits behind its own consent permission.
- `onBeforeBlobUpload` can divert an attachment to external storage before it reaches the server, and `api.http.post` takes binary `Blob` / `File` bodies to get it there
- Plugins hot-reload, load from a dev folder, bundle `src/` on demand through esbuild, and can request `http:fetch` scoped by `httpOrigins`
- Themes upload as ZIP bundles, and admins can enforce one
- An extension marketplace browses and installs plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`). Installing and uninstalling stay in the admin dashboard.
- Bundled plugins, including Jitsi Meet for the calendar
## Operations
- Progressive Web App: service worker, install prompt, web push for new inbox mail, a dynamic manifest, and install screenshots configurable per domain
- The install prompt and the background-notification prompt are sequenced, so the two never compete for the same moment. Neither appears on login, setup, or settings screens.
- Update checks run on their own, log new releases server-side, and raise a notice that can't be dismissed
- Structured logging (`text` or `json`) with per-category levels
- Anonymous instance telemetry, off unless you enable it through the admin UI, the installer, or `BULWARK_TELEMETRY=on`. It reports version, platform, bucketed account counts, and feature toggles.
- Docker images on GHCR, for release (`main`) and development (`dev`)
- Bulwark Lite: a static export of the client (`npm run build:lite`, `bulwark-lite-<version>.zip` on every release) for plain static hosts; runtime `config.json`, cookie-free token login against Stalwart, no admin/plugin/sync features
- Bulwark Lite for Stalwart: `bulwark-lite-stalwart.zip` installs as a Stalwart `Application` (served by Stalwart itself under any prefix, same origin, zero configuration)
- `NEXT_PUBLIC_BASE_PATH` mounts the app at a subpath behind a reverse proxy
- Demo mode runs on fixture data, no mail server required
+112 -416
View File
@@ -1,461 +1,157 @@
<div align="center">
<p>
<a href="https://bulwarkmail.org"><img src="https://raw.githubusercontent.com/bulwarkmail/.github/main/profile/banner.png" alt="Bulwark: webmail for Stalwart Mail Server. Mail, calendar, contacts and files in one browser client." width="100%" /></a>
</p>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture>
<p align="center">
<a href="https://github.com/bulwarkmail/webmail/releases/latest"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/.github/main/badges/release-dark.svg" /><img src="https://raw.githubusercontent.com/bulwarkmail/.github/main/badges/release.svg" alt="latest release" height="24" /></picture></a>&nbsp;
<a href="https://github.com/bulwarkmail/webmail/pkgs/container/webmail"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/.github/main/badges/docker-dark.svg" /><img src="https://raw.githubusercontent.com/bulwarkmail/.github/main/badges/docker.svg" alt="docker: ghcr.io/bulwarkmail/webmail" height="24" /></picture></a>&nbsp;
<a href="LICENSE"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/.github/main/badges/license-dark.svg" /><img src="https://raw.githubusercontent.com/bulwarkmail/.github/main/badges/license.svg" alt="license: AGPL v3" height="24" /></picture></a>&nbsp;
<a href="https://discord.gg/tYCujymGrT"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/.github/main/badges/discord-dark.svg" /><img src="https://raw.githubusercontent.com/bulwarkmail/.github/main/badges/discord.svg" alt="Discord members" height="24" /></picture></a>
</p>
# Bulwark Webmail
<p align="center">
<a href="https://bulwarkmail.org">Website</a> ·
<a href="https://bulwarkmail.org/docs">Documentation</a> ·
<a href="https://demo.bulwarkmail.org">Live demo</a> ·
<a href="https://github.com/bulwarkmail/webmail/releases">Releases</a> ·
<a href="https://discord.gg/tYCujymGrT">Discord</a>
</p>
A self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
Bulwark Webmail is a self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/). It talks to Stalwart over JMAP and puts mail, calendar, contacts and files behind one login, with one set of settings and one admin dashboard.
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.10.0-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div>
<p align="center">
<a href="https://bulwarkmail.org/docs/features/overview"><picture><source media="(prefers-color-scheme: dark)" srcset="screenshots/dark-laptop-phone.webp" /><img src="screenshots/light-laptop-phone.webp" alt="Bulwark inbox on a laptop, with the same message open on a phone" width="100%" /></picture></a>
</p>
## Contents
- [Screenshots](#screenshots)
- [Features](#features)
- [Quick start](#quick-start)
- [Other ways to install](#other-ways-to-install)
- [Configuration](#configuration)
- [Documentation](#documentation)
- [Development](#development)
- [Use of AI](#use-of-ai)
- [Community and support](#community-and-support)
- [License](#license)
## Screenshots
<picture>
<source media="(prefers-color-scheme: dark)" srcset="screenshots/mail-dark.png" />
<img src="screenshots/mail-white.png" alt="Mail view" width="100%" />
</picture>
<p align="center">
<a href="https://bulwarkmail.org/docs/features/calendar"><picture><source media="(prefers-color-scheme: dark)" srcset="screenshots/dark-calendar-week.webp" /><img src="screenshots/light-calendar-week.webp" alt="Calendar week view with events from several calendars" width="49%" /></picture></a>
<a href="https://bulwarkmail.org/docs/features/contacts"><picture><source media="(prefers-color-scheme: dark)" srcset="screenshots/dark-contact.webp" /><img src="screenshots/light-contact.webp" alt="Contact details with recent mail and upcoming events" width="49%" /></picture></a>
<br />
<sub>Calendar week view&nbsp;&nbsp;·&nbsp;&nbsp;Contact details with recent mail and upcoming events</sub>
</p>
<table>
<tr>
<td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
</tr>
<tr>
<td><sub><b>Calendar</b> – month, week, day, and agenda views with drag-to-reschedule, iMIP invitations, and CalDAV subscriptions.</sub></td>
<td><sub><b>Contacts</b> – multiple address books, groups, vCard import/export, and autocomplete in the composer.</sub></td>
</tr>
<tr>
<td><img src="screenshots/theme.png" alt="Themes" /></td>
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
</tr>
<tr>
<td><sub><b>Themes</b> – bundled color themes or upload your own as ZIP bundles; admins can enforce presets.</sub></td>
<td><sub><b>Plugins</b> – extend the client with bundled or third-party plugins installed from a .zip file.</sub></td>
</tr>
<tr>
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
<td><img src="screenshots/settings.png" alt="Settings" /></td>
</tr>
<tr>
<td><sub><b>Light mode</b> – full theme support, remapping HTML email colors by luminance so dark-on-dark text stays readable.</sub></td>
<td><sub><b>Settings</b> – appearance, identities, filters, templates, security, and more.</sub></td>
</tr>
</table>
<p align="center">
<a href="https://bulwarkmail.org/docs/features/email/composing"><picture><source media="(prefers-color-scheme: dark)" srcset="screenshots/dark-composer.webp" /><img src="screenshots/light-composer.webp" alt="Rich-text composer next to the message list" width="49%" /></picture></a>
<a href="https://bulwarkmail.org/docs/guides/customization"><img src="screenshots/split-inbox.webp" alt="The inbox split diagonally between the light and dark theme" width="49%" /></a>
<br />
<sub>Composer with identities and formatting&nbsp;&nbsp;·&nbsp;&nbsp;Light and dark themes</sub>
</p>
## What Bulwark includes
## Features
Bulwark is a full webmail suite. It bundles the four apps most self-hosters end up wanting:
- [Mail](https://bulwarkmail.org/docs/features/email): threading, unified inbox, cross-account views, [full-text search](https://bulwarkmail.org/docs/features/email/search), Sieve filters, [S/MIME](https://bulwarkmail.org/docs/guides/smime), templates, scheduled send
- [Calendar](https://bulwarkmail.org/docs/features/calendar): month, week, day and agenda views, recurring events, iMIP invitations, CalDAV subscriptions
- [Contacts](https://bulwarkmail.org/docs/features/contacts): several address books, groups, vCard import and export
- [Files](https://bulwarkmail.org/docs/features/files): Stalwart's JMAP file storage, with previews, sharing and folder upload
- **Mail** – threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
- **Contacts** – multiple address books, groups, vCard import/export
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
All four share [single sign-on](https://bulwarkmail.org/docs/getting-started/configuration/authentication) and [2FA](https://bulwarkmail.org/docs/guides/account-security), [multiple accounts](https://bulwarkmail.org/docs/guides/multi-account), 27 languages, [PWA install and web push](https://bulwarkmail.org/docs/features/pwa), [themes](https://bulwarkmail.org/docs/guides/customization), [plugins](https://bulwarkmail.org/docs/guides/plugins) and [keyboard shortcuts](https://bulwarkmail.org/docs/guides/keyboard-shortcuts). The full list is on the [All features](https://bulwarkmail.org/docs/features/overview) page.
They share one login, one settings store, and one admin dashboard. SSO, 2FA, multi-account, 27 languages, PWA install, themes, and plugins apply across all four.
Full feature list: **[FEATURES.md](FEATURES.md)**.
---
There are two [editions](https://bulwarkmail.org/docs/getting-started/editions). The full edition runs as a Node.js server and adds the admin dashboard, OAuth, plugins and settings sync. [Bulwark Lite](https://bulwarkmail.org/docs/getting-started/lite) is the same client as static files, served by any web host or by Stalwart itself.
## Quick start
### Docker
```bash
docker run -d -p 3000:3000 ghcr.io/bulwarkmail/webmail:latest
```
Or with Docker Compose:
Then open `http://localhost:3000`. A setup wizard asks for your Stalwart server and an admin password. The [installation guide](https://bulwarkmail.org/docs/getting-started/installation) has the details, and [Stalwart setup](https://bulwarkmail.org/docs/getting-started/configuration/stalwart-setup) covers the mail server side.
```bash
docker compose up -d
## Other ways to install
| Method | Guide |
| --- | --- |
| Docker Compose | [Compose](https://bulwarkmail.org/docs/deployment/docker/compose) |
| Behind a reverse proxy or on a sub-path | [Reverse proxy](https://bulwarkmail.org/docs/deployment/docker/reverse-proxy) |
| From source, without Docker | [Manual install](https://bulwarkmail.org/docs/deployment/manual) |
| Lite on any static host | [Static hosting](https://bulwarkmail.org/docs/deployment/static) |
| Lite as a container | [Container image](https://bulwarkmail.org/docs/deployment/static#container-image) |
| Lite served by Stalwart | [Install on Stalwart](https://bulwarkmail.org/docs/deployment/stalwart-app) |
[Updating](https://bulwarkmail.org/docs/deployment/updating) explains how to move to a new version.
Container images and release files published after 1.11.2 come with a signed build provenance attestation. To check that one was built by this repository's workflows, run `gh attestation verify oci://ghcr.io/bulwarkmail/webmail:<version> --owner bulwarkmail` for an image, or `gh attestation verify <file> --repo bulwarkmail/webmail` for a downloaded file. Release files also have a `.sha256` next to them.
## Configuration
Most installs are set up in the wizard on first launch and changed later in the [admin dashboard](https://bulwarkmail.org/docs/guides/admin). You can also use environment variables, which fit read-only or immutable deployments better. When both set the same key, the value saved in the admin config wins, so an environment variable only fills in what the admin config leaves unset.
```env
JMAP_SERVER_URL=https://mail.example.com
APP_NAME=My Webmail
```
On first launch, open `http://localhost:3000` and the setup wizard takes over. Installs that already define `JMAP_SERVER_URL` skip it and keep the env-managed flow under [Configuration](#configuration).
| Topic | Guide |
| --- | --- |
| Overview, config files and precedence | [Configuration](https://bulwarkmail.org/docs/getting-started/configuration) |
| Every variable | [Environment reference](https://bulwarkmail.org/docs/getting-started/configuration/environment-reference) |
| OAuth2 / OIDC and single sign-on | [Authentication](https://bulwarkmail.org/docs/getting-started/configuration/authentication), [Embedded SSO](https://bulwarkmail.org/docs/guides/embedded-sso) |
| Several JMAP servers, custom endpoints | [Multi-server deployments](https://bulwarkmail.org/docs/getting-started/configuration/stalwart-setup#multi-server-deployments), [Custom endpoints](https://bulwarkmail.org/docs/getting-started/configuration#custom-jmap-server-endpoints) |
| Branding, logos, per-domain branding | [Customization](https://bulwarkmail.org/docs/guides/customization) |
| Anonymous telemetry (off by default) | [Anonymous usage stats](https://bulwarkmail.org/docs/features/telemetry) |
### From source
## Documentation
All documentation is at [bulwarkmail.org/docs](https://bulwarkmail.org/docs):
- Getting started: [Introduction](https://bulwarkmail.org/docs/getting-started/introduction), [Installation](https://bulwarkmail.org/docs/getting-started/installation), [Editions](https://bulwarkmail.org/docs/getting-started/editions), [Bulwark Lite](https://bulwarkmail.org/docs/getting-started/lite), [Demo mode](https://bulwarkmail.org/docs/getting-started/demo-mode)
- Deployment: [Docker](https://bulwarkmail.org/docs/deployment/docker), [Manual install](https://bulwarkmail.org/docs/deployment/manual), [Static hosting](https://bulwarkmail.org/docs/deployment/static), [Install on Stalwart](https://bulwarkmail.org/docs/deployment/stalwart-app), [Updating](https://bulwarkmail.org/docs/deployment/updating)
- Guides: [Admin dashboard](https://bulwarkmail.org/docs/guides/admin), [Account security](https://bulwarkmail.org/docs/guides/account-security), [Impersonation](https://bulwarkmail.org/docs/guides/impersonation), [Plugins](https://bulwarkmail.org/docs/guides/plugins), [Marketplace](https://bulwarkmail.org/docs/guides/marketplace), [Troubleshooting](https://bulwarkmail.org/docs/guides/troubleshooting)
- Extensions: [Introduction](https://bulwarkmail.org/docs/extensions/introduction), [manifest.json](https://bulwarkmail.org/docs/extensions/manifest), [Publishing](https://bulwarkmail.org/docs/extensions/publishing)
- Development: [Architecture](https://bulwarkmail.org/docs/development/architecture), [Contributing](https://bulwarkmail.org/docs/development/contributing)
- Legal: [Privacy](https://bulwarkmail.org/docs/legal/privacy)
The pages are Markdown files in the [website repository](https://github.com/bulwarkmail/website/tree/main/docs). Send corrections there.
## Development
```bash
git clone https://github.com/bulwarkmail/webmail.git
cd webmail
npm install
npm run build && npm start
# Then open http://localhost:3000 to run the setup wizard
cp .env.dev.example .env.local # built-in mock JMAP server, no mail server needed
npm run dev
```
### Static Lite build
Bulwark Lite is the same client exported as static files: no Node.js process, upload the folder to any static host (nginx, Caddy, Netlify, Cloudflare Pages, GitHub Pages, an S3 bucket). Mail, calendar, contacts and files talk to the JMAP server straight from the browser, so the only server-side requirement is CORS on the mail server (`http.permissive-cors = true` in Stalwart).
Every release ships `bulwark-lite-<version>.zip` (and `bulwark-lite-stalwart.zip`, see [Install on Stalwart](#install-on-stalwart)), and the **Build Static Lite** workflow can be dispatched with a fixed server URL, app name, sub-path mount, locale subset or the demo flag. To build locally:
```bash
git worktree add ../bulwark-lite HEAD # disposable checkout: the build deletes server-only trees
cd ../bulwark-lite && npm ci
LITE_JMAP_SERVER_URL=https://mail.example.com npm run build:lite -- --in-place
npm run lite:serve # http://localhost:4173/
```
`out/` then holds one shell per locale and surface plus the deployer files: `config.json` (server URL, app name, login branding; editable without a rebuild), `policy.json`, `manifest.webmanifest`, `_redirects` and `_headers` (Netlify/Cloudflare), `nginx.conf.example`, `Caddyfile.example`, a `404.html` that replays deep links on hosts without rewrite rules, and `LITE-README.md` with the three-step setup. Build inputs: `LITE_JMAP_SERVER_URL`, `LITE_APP_NAME`, `LITE_ALLOW_CUSTOM_ENDPOINT`, `LITE_REMEMBER_ME`, `LITE_DEMO_MODE`, `LITE_LOCALES` (e.g. `en,de`), `NEXT_PUBLIC_BASE_PATH`.
Lite keeps everything that runs in the browser (multi-account, password and TOTP login, "remember me" via Stalwart refresh tokens, themes, deep links, demo mode). Server-backed features are off: the admin console and setup wizard, plugins and sidebar apps, settings sync, OAuth/SSO, the account security tab, ICS URL subscriptions and CalDAV discovery, sender favicons, office editing, web push, the update banner, the "Default apps" protocol-handler registration. Password logins go through Stalwart's token endpoints in the browser (`/api/auth` + `/auth/token`, so those need CORS as well); without "remember me" the refresh token lives in sessionStorage and the session ends with the tab. `scripts/lite/verify.mjs` fails the build if a client chunk references a server endpoint that is not documented in `scripts/lite/lib.mjs`, and `npm run test:lite-smoke` drives a demo export with Playwright.
#### Install on Stalwart
Stalwart 0.16 can host the webmail itself as an `Application`: it downloads a zip, serves it under a path of its own HTTP listener, and the webmail talks to the same origin's JMAP, `/api/auth` and `/auth/token`, so there is no server URL to enter and no CORS to set up. Every release ships `bulwark-lite-stalwart.zip` for this (built with `npm run build:lite -- --target=stalwart`). An administrator creates the Application and then asks Stalwart to fetch it. Creating it alone mounts nothing:
```bash
stalwart-cli create Application \
--field description='Bulwark Webmail' \
--field resourceUrl='https://github.com/bulwarkmail/webmail/releases/latest/download/bulwark-lite-stalwart.zip' \
--field 'urlPrefix={"/webmail":true}'
stalwart-cli create Action/UpdateApps
```
The same in the web admin: **Settings > Web Applications**, create the entry, then run the "update applications" action (or restart Stalwart). Over JMAP, as an administrator:
```json
{
"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
"methodCalls": [
["x:Application/set", { "create": { "webmail": {
"description": "Bulwark Webmail",
"resourceUrl": "https://github.com/bulwarkmail/webmail/releases/latest/download/bulwark-lite-stalwart.zip",
"urlPrefix": { "/webmail": true }
} } }, "0"],
["x:Action/set", { "create": { "u": { "@type": "UpdateApps" } } }, "1"]
]
}
```
Then open `https://<stalwart host>/webmail/`. Notes:
- **Permissions**: `sysApplicationCreate` (plus `sysApplicationGet`/`Query`/`Update`/`Destroy` to manage it) and `actionUpdateApps`.
- **Prefix**: `urlPrefix` matches the first path segment only, and one bundle may be mounted under several prefixes; it reads its prefix at runtime. Stalwart routes these segments itself, so they cannot host an Application: `jmap`, `dav`, `.well-known`, `auth`, `api`, `scim`, `mail`, `calendar`, `autodiscover`, `login`, `device`, `metrics`, `healthz`, `logo`, `form`, `robots.txt`, and the web admin's `admin` and `account`.
- **Updates**: Stalwart caches the zip for `autoUpdateFrequency` (default 90 days) and downloads `resourceUrl` again at the next restart or `UpdateApps` after that. `UpdateApps` always re-downloads, so it is also how to update right away. Open tabs switch to the new build on their next navigation (one full page load); files the app fetches without a content hash carry the build id, so Stalwart's year-long cache headers never pin an old build.
- **Pinning**: use `https://github.com/bulwarkmail/webmail/releases/download/v<version>/bulwark-lite-stalwart.zip` as `resourceUrl`.
- **OAuth client**: login uses the Application's `oauthClientId`, or `bulwark-webmail` when it is empty. If client registration is required, register that id with the redirect URI `https://<stalwart host>/<prefix>/` (one per prefix).
- **Minimum version**: Stalwart 0.16.0 (0.16.19 for `oauthClientId`).
- **Differences from the static-host zip**: one entry document boots every route (Stalwart has no rewrite rules), no `_redirects`/`_headers`/nginx/Caddy files and no `404.html`, `config.json` defaults to the serving Stalwart and hides the server field, and `manifest.json` replaces `manifest.webmanifest`. The bundle is read-only; to rename or rebrand it, run the **Build Static Lite** workflow (or the build above with `LITE_APP_NAME` etc.), host the resulting `bulwark-lite-stalwart.zip` yourself and point `resourceUrl` at it.
### Development
```bash
cp .env.dev.example .env.local # Built-in mock JMAP server, no mail server needed
npm run dev # Dev server
npm run typecheck
npm run lint
npx vitest run # Unit tests
npm run test:integration # Dockerized Stalwart + Playwright suite (see integration/README.md)
npx vitest run # unit tests
npm run test:integration # Stalwart in Docker + Playwright
```
## Configuration
The [contributing guide](https://bulwarkmail.org/docs/development/contributing) covers tests, translations, code style and pull requests. [Architecture](https://bulwarkmail.org/docs/development/architecture) explains how the code is organized.
Most deployments are configured through the setup wizard on first launch, then the admin dashboard; those values live in the admin config directory rather than `.env.local`. Environment variables still work, and they suit read-only or immutable infrastructure better. An environment variable always wins over the admin-managed value, so setting `JMAP_SERVER_URL` hides that field from the wizard and locks it in the admin UI.
The stack: [Next.js 16](https://nextjs.org/) and React 19, TypeScript, [Tailwind CSS v4](https://tailwindcss.com/), [Zustand](https://zustand-demo.pmnd.rs/), [Tiptap](https://tiptap.dev/), [next-intl](https://next-intl-docs.vercel.app/), [Tabler Icons](https://tabler.io/icons), and our own JMAP client (RFC 8620). Tests run on [Vitest](https://vitest.dev/) and [Playwright](https://playwright.dev/).
Nearly all variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. The exceptions are the `NEXT_PUBLIC_*` ones noted below, which Next.js bakes in at build time. Edit `.env.local`:
## Use of AI
```env
# Optional – overrides whatever the wizard writes
JMAP_SERVER_URL=https://mail.example.com
APP_NAME=My Webmail
```
AI tools are part of how Bulwark is developed. A change is held to the same standard whether a person or a tool wrote it: it has to be understood, reviewed and pass the checks and tests before it is merged.
<details>
<summary>Server listen address</summary>
Bulwark itself has no AI features and doesn't send your mail, contacts, calendar or files to an AI service. If your Stalwart server classifies spam with an LLM, Bulwark shows the verdict, but the classification happens on your server.
```env
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
PORT=3000
```
Contributors can use AI tools too, as long as they use capable, current models and not small or outdated ones. The [contributing guide](https://bulwarkmail.org/docs/development/contributing#ai-assisted-contributions) explains what we expect.
</details>
## Community and support
<details>
<summary>OAuth2 / OIDC</summary>
```env
OAUTH_ENABLED=true
OAUTH_ONLY=true # hide the username/password form entirely
OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
OAUTH_ISSUER_URL= # optional, for external IdPs
OAUTH_AUTHORIZE_URL= # override only the user-facing authorize endpoint
OAUTH_ALLOW_PRIVATE_ENDPOINTS= # allow discovery to resolve to RFC-1918 addresses
OAUTH_SCOPES= # replace the requested scopes (space-separated)
OAUTH_EXTRA_SCOPES= # append to the defaults instead of replacing them
AUTO_SSO_ENABLED=true # skip the login form, go straight to the IdP
```
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. `OAUTH_ALLOW_PRIVATE_ENDPOINTS` is off by default as an SSRF guard. Enable it only for split-DNS deployments where the issuer's public hostname resolves to an internal IP.
</details>
<details>
<summary>Anonymous telemetry</summary>
```env
BULWARK_TELEMETRY=on # opt-in; off by default
TELEMETRY_DATA_DIR=./data/telemetry # instance id and consent; mount a volume
```
Off unless you turn it on, in the admin UI, the installer, or here. Heartbeats carry version, platform, bucketed account counts, and feature toggles. No email addresses, hostnames, or IPs. Setting the variable (to either value) locks the choice and disables the admin toggle.
</details>
<details>
<summary>Session & settings sync</summary>
```env
SESSION_SECRET= # openssl rand -base64 32
SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
SETTINGS_SYNC_ENABLED=true
SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker
```
Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
</details>
<details>
<summary>Multiple JMAP servers & custom endpoints</summary>
```env
ALLOW_CUSTOM_JMAP_ENDPOINT=true
JMAP_SERVERS=[{"id":"eu","label":"Europe","url":"https://eu.example.com","domains":["example.com"]},{"id":"us","label":"US","url":"https://us.example.com"}]
JMAP_SERVER_AUTO_PICK_BY_DOMAIN=true
```
`ALLOW_CUSTOM_JMAP_ENDPOINT` shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
`JMAP_SERVERS` offers a fixed list instead; each entry needs `id`, `label`, and `url`, and may carry `domains` and its own `oauth` block. With `JMAP_SERVER_AUTO_PICK_BY_DOMAIN`, the domain of the address the user types selects the server. The admin dashboard manages the same list — the env form is for stateless deployments.
</details>
<details>
<summary>Branding & PWA</summary>
```env
APP_NAME=My Webmail
APP_SHORT_NAME=Webmail
APP_DESCRIPTION=Your personal mail
FAVICON_URL=/branding/favicon.svg
PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL
PWA_THEME_COLOR=#3b82f6
PWA_BACKGROUND_COLOR=#ffffff
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
APP_LOGO_DARK_URL=/branding/logo-dark.svg
LOGIN_LOGO_LIGHT_URL=/branding/login-light.svg
LOGIN_LOGO_DARK_URL=/branding/login-dark.svg
LOGIN_COMPANY_NAME=My Company
LOGIN_WEBSITE_URL=https://example.com
LOGIN_IMPRINT_URL=https://example.com/imprint
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
# Web push goes through a hosted relay, so no VAPID keys or Firebase project
# of your own. Point this at your own relay to opt out. Build-time variable.
NEXT_PUBLIC_PUSH_RELAY_URL=https://notifications.relay.bulwarkmail.org
# Per-domain overrides (optional). When the webmail is served on multiple
# hostnames, each host can override any subset of the branding fields above.
# Match is on the request Host (or X-Forwarded-Host). Use "*.example.com" to
# match any subdomain. Unset fields fall back to the global values.
DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one.svg"},{"host":"maildomain2.com","loginCompanyName":"Company Two"}]
```
</details>
<details>
<summary>Extension directory</summary>
```env
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
PLUGIN_DEV_DIR=../my-plugins # load plugins from disk instead of ZIPs
```
`EXTENSION_DIRECTORY_URL` enables the admin marketplace for browsing and installing plugins and themes. `PLUGIN_DEV_DIR` is for plugin authors: each immediate subfolder is one plugin with a `manifest.json`, and an entrypoint under `src/` is bundled on demand with esbuild, so editing sources needs only a browser refresh.
Sandboxed plugins that integrate provider-side labels can use the native
`api.keywords` facade exposed by `@plugin-host`:
```js
const api = require('@plugin-host');
const known = await api.keywords.list(); // settings:read
const scan = await api.jmap.getKeywords(); // email:read
const providerLabel = scan.labels
.find((label) => label.id.startsWith('$label:'));
if (providerLabel) {
await api.keywords.add([{ // settings:write
id: providerLabel.id.slice('$label:'.length),
label: providerLabel.name,
// color is optional; Bulwark picks a palette colour when omitted
visibility: 'show',
}]);
}
const current = await api.keywords.list(); // settings:read
await api.keywords.reorder(current.map(({ id }) => id), { // settings:write
caseSensitive: false, // default
});
const counts = await api.keywords.refreshCounts(); // email:read
// Complete replacement: keywords omitted here are removed from the message.
await api.jmap.setKeywords('email-id', { // email:write
'$seen': true,
'$label:provider-label-id': true,
});
await api.jmap.setKeyword('email-id', '$label:work'); // email:write
await api.jmap.removeKeyword('email-id', '$label:work'); // email:write
```
`jmap.getKeywords()` is a narrow read-only facade rather than an arbitrary JMAP
request API. When the JMAP server advertises
`https://bulwarkmail.com/ns/jmap/keywords`, it returns all cached keywords with
exact total/unread counts and provider-label metadata, including empty provider
labels. On servers without the capability it falls back to a bounded scan of
message keywords. `keywords.discover()` retains its original message-scan
response for compatibility.
`jmap.setKeywords()` replaces one message's complete keyword map via
`Email/set`. Omitted keywords are removed, so extensions should use the existing
`jmap.setKeyword()` and `jmap.removeKeyword()` methods for incremental edits.
The existing `email.setKeyword()` and `email.removeKeyword()` names remain as
compatibility aliases.
`keywords.add()` is append-only and case-insensitive by id: it returns added
and skipped definitions without overwriting the user's existing label name,
colour, visibility, or order. `keywords.reorder()` accepts a complete
permutation of the existing label ids and changes only their order; missing,
unknown, or duplicate ids are rejected without changing settings. Matching is
case-insensitive by default; pass `{ caseSensitive: true }` to require exact id
casing. Keyword discovery reports whether its bounded scan was complete.
</details>
<details>
<summary>Stalwart integration & logging</summary>
```env
STALWART_FEATURES=true # password change, Sieve filters, etc.
LOG_FORMAT=text # "text" or "json"
LOG_LEVEL=info # error | warn | info | debug
```
</details>
<details>
<summary>Admin data directories</summary>
```env
ADMIN_CONFIG_DIR=./data/admin # operator-authored: config.json, policy.json, plugins/, themes/
ADMIN_STATE_DIR=./data/admin-state # runtime: audit log, login timestamps, setup token
ADMIN_CONFIG_READONLY=true # enforce read-only mode at the app layer
```
The split lets you mount the config volume read-only after the setup wizard completes. Legacy installs that pre-date the split keep working through `ADMIN_DATA_DIR`.
</details>
<details>
<summary>Default UI locale</summary>
The UI language follows each visitor's `Accept-Language` header and their stored preference. `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback used when neither matches a supported locale (default `en`):
```env
NEXT_PUBLIC_DEFAULT_LOCALE=de
```
Supported: `ar`, `ca`, `cs`, `da`, `de`, `en`, `es`, `fa`, `fr`, `he`, `hu`, `it`, `ja`, `ko`, `lv`, `nl`, `pl`, `pt`, `ro`, `ru`, `sk`, `tr`, `uk`, `zh`. An unsupported value falls back to `en`.
Like `NEXT_PUBLIC_BASE_PATH`, this is read at **build time**. To use it with the published Docker image, build your own:
```bash
docker build --build-arg NEXT_PUBLIC_DEFAULT_LOCALE=de -t bulwark-webmail .
```
</details>
<details>
<summary>Subpath / reverse proxy mount</summary>
To serve the webmail at a subpath (e.g. `https://example.com/webmail`):
```env
NEXT_PUBLIC_BASE_PATH=/webmail
NEXT_PUBLIC_LOCALE_PREFIX=always # avoids next-intl rewrite loops
```
Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** because Next.js bakes it into emitted asset URLs. To use it with the published Docker image, build your own image with the variable set:
```bash
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
```
Then point your reverse proxy at the container without stripping the prefix. The app expects requests under `/webmail/...` and serves every route (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, and so on) accordingly.
</details>
## Keyboard shortcuts
| Key | Action |
| -------------------- | ----------------------- |
| `j` `↓` / `k` `↑` | Navigate between emails |
| `Enter` / `o` | Open email |
| `Esc` | Close / deselect |
| `x` | Expand / collapse thread |
| `c` | Compose |
| `r` / `R` `a` | Reply / Reply all |
| `f` | Forward |
| `s` | Star |
| `e` | Archive |
| `#` / `Del` | Delete |
| `u` / `Shift`+`I` | Mark unread / read |
| `!` | Toggle spam |
| `Ctrl`+`A` | Select all |
| `Shift`+`G` | Refresh |
| `/` | Search |
| `?` | Show all shortcuts |
In the composer: `Ctrl/Cmd`+`Enter` sends, `Ctrl/Cmd`+`Shift`+`Enter` opens scheduled send, and `t` opens the template picker.
## Tech stack
| | |
| ------------- | ------------------------------------------------- |
| **Framework** | [Next.js 16](https://nextjs.org/) with App Router, React 19 |
| **Language** | TypeScript |
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) |
| **State** | [Zustand](https://zustand-demo.pmnd.rs/) |
| **Protocol** | Custom JMAP client (RFC 8620) |
| **Editor** | [Tiptap](https://tiptap.dev/) |
| **i18n** | [next-intl](https://next-intl-docs.vercel.app/) |
| **Icons** | [Lucide React](https://lucide.dev/) |
| **Testing** | [Vitest](https://vitest.dev/) + [Playwright](https://playwright.dev/) |
## Why Stalwart?
[Stalwart](https://github.com/stalwartlabs/mail-server) is a Rust mail server with native JMAP support – not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single self-hosted binary with no third-party dependencies.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
- Questions: check [Troubleshooting](https://bulwarkmail.org/docs/guides/troubleshooting), then ask on [Discord](https://discord.gg/tYCujymGrT).
- Bugs and feature requests: open a [GitHub issue](https://github.com/bulwarkmail/webmail/issues).
- Security vulnerabilities: report them privately to [dev@bulwarkmail.org](mailto:dev@bulwarkmail.org) or through a [security advisory](https://github.com/bulwarkmail/webmail/security/advisories/new), never in a public issue.
- Release notes: [CHANGELOG.md](CHANGELOG.md) and the [GitHub releases](https://github.com/bulwarkmail/webmail/releases).
## License
+1 -1
View File
@@ -1 +1 @@
1.10.0
1.11.2
+19 -4
View File
@@ -1,12 +1,13 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { Suspense, useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
import { apiFetch, getPathPrefix, toRouterPath } from "@/lib/browser-navigation";
import { buildSettingsPath } from "@/lib/deep-links";
import { Loader2, AlertCircle } from "lucide-react";
import { LITE_OAUTH_AVAILABLE, getLiteOAuthRedirectUri } from "@/lib/auth/lite-oauth";
import { Loader2, AlertCircle } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
@@ -17,8 +18,14 @@ function OAuthCallbackInner() {
const t = useTranslations("login");
const { loginWithOAuth, loginWithServerSso } = useAuthStore();
const [error, setError] = useState<string | null>(null);
// The authorization code is single-use. React's development double effect
// ran this twice: the second pass no longer saw the pairing re-auth flag
// (the first had removed it) and spent the code on the login flow instead.
const handledRef = useRef(false);
useEffect(() => {
if (handledRef.current) return;
handledRef.current = true;
const code = searchParams.get("code");
const state = searchParams.get("state");
const errorParam = searchParams.get("error");
@@ -37,9 +44,13 @@ function OAuthCallbackInner() {
// via prompt=login. Don't create a login session — just confirm the fresh
// auth (sets the short-lived pairing proof cookie) and bounce back to the
// Security settings, where the QR generation auto-resumes.
// The flag holds the state the step-up started with; one left behind by an
// abandoned round trip is dropped here instead of capturing this login.
let pairReauthResume = false;
try {
pairReauthResume = sessionStorage.getItem("pair_reauth_resume") === "1";
const resumeState = sessionStorage.getItem("pair_reauth_resume");
pairReauthResume = !!state && resumeState === state;
if (resumeState !== null && !pairReauthResume) sessionStorage.removeItem("pair_reauth_resume");
} catch { /* sessionStorage unavailable */ }
if (pairReauthResume && state) {
try { sessionStorage.removeItem("pair_reauth_resume"); } catch { /* ignore */ }
@@ -90,7 +101,11 @@ function OAuthCallbackInner() {
}
const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
// Lite on Stalwart registers one locale-free URI (lib/auth/lite-oauth.ts);
// the entry document brought us here from it.
const redirectUri = LITE_OAUTH_AVAILABLE
? getLiteOAuthRedirectUri()
: `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri, serverId)
.then((success) => {
+1 -1
View File
@@ -2,7 +2,7 @@
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { AlertCircle, RefreshCw, Home } from "lucide-react";
import { AlertCircle, RefreshCw, Home } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { useRouter } from "@/i18n/navigation";
+144 -16
View File
@@ -6,7 +6,7 @@ import { useParams, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store";
import { useAuthStore, consumeSignedOut } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
@@ -14,19 +14,39 @@ import { useConfig } from "@/hooks/use-config";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "@/components/icons";
import { type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { useUpdateStore, selectBanner } from "@/stores/update-store";
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
import { IS_LITE } from "@/lib/lite";
import { probeLiteTokenLogin } from "@/lib/auth/lite-tokens";
import { IS_LITE, getLiteInjectedClientId } from "@/lib/lite";
import { getLiteClientId, probeLiteTokenLogin } from "@/lib/auth/lite-tokens";
import {
LITE_OAUTH_AVAILABLE,
getLiteOAuthRedirectUri,
liteDiscoverOAuth,
saveLiteOAuthFlow,
type LiteOAuthDiscovery,
} from "@/lib/auth/lite-oauth";
import { toAsciiDomain } from "@/lib/idn";
/** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */
function completeAddressDomain(username: string): string {
const match = /^[^@\s]+@([^@\s]+\.[^@\s.]+)$/.exec(username.trim());
return match ? match[1].toLowerCase() : "";
}
/** Compare domains in ASCII form, so `bücher.de` matches a configured `xn--bcher-kva.de` and back. */
function domainKey(domain: string): string {
return toAsciiDomain(domain) ?? domain.trim().toLowerCase();
}
function findServerByDomain(servers: PublicJmapServerEntry[], email: string | undefined): PublicJmapServerEntry | undefined {
if (!email || !email.includes("@")) return undefined;
const domain = email.split("@")[1]?.trim().toLowerCase();
const domain = email.split("@")[1]?.trim();
if (!domain) return undefined;
return servers.find((s) => (s.domains ?? []).some((d) => d.toLowerCase() === domain));
const key = domainKey(domain);
return servers.find((s) => (s.domains ?? []).some((d) => domainKey(d) === key));
}
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
@@ -228,6 +248,33 @@ function LoginPageContent() {
}, [probeTarget]);
const showRememberMe = rememberMeEnabled && (!IS_LITE || liteTokenLoginSupported !== false);
// Lite on Stalwart: which provider signs the typed account in
// (lib/auth/lite-oauth.ts). A domain Stalwart delegates to an external
// OpenID provider has no password to check, so the form turns into the SSO
// button for it. The answer depends on the domain, so it is kept while the
// local part is edited, and only asked for once the address is complete
// (the endpoint is rate limited).
const [liteOAuthByDomain, setLiteOAuthByDomain] = useState<{ domain: string; discovery: LiteOAuthDiscovery | null } | null>(null);
const [liteOAuthFailed, setLiteOAuthFailed] = useState(false);
const liteDomain = LITE_OAUTH_AVAILABLE ? completeAddressDomain(formData.username) : "";
const liteAccount = liteDomain ? formData.username.trim() : "";
useEffect(() => {
if (!liteAccount || !probeTarget) return;
const controller = new AbortController();
const timer = setTimeout(() => {
liteDiscoverOAuth(probeTarget, liteAccount, controller.signal).then((discovery) => {
if (!controller.signal.aborted) setLiteOAuthByDomain({ domain: completeAddressDomain(liteAccount), discovery });
});
}, 600);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [probeTarget, liteAccount]);
const liteSsoRequired = liteDomain !== "" && liteOAuthByDomain?.domain === liteDomain && liteOAuthByDomain.discovery?.external === true;
// The admin gave the Application an OAuth client: offer SSO next to the form.
const liteSsoOffered = LITE_OAUTH_AVAILABLE && !liteSsoRequired && getLiteInjectedClientId() !== "";
useEffect(() => {
if (serverUrl) {
document.title = appName;
@@ -398,6 +445,8 @@ function LoginPageContent() {
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
const ssoError = searchParams.get("sso_error");
const autoSsoTriggered = useRef(false);
// Read once per mount: a ref survives the effect re-runs, the flag does not.
const signedOutOnPurpose = useRef<boolean | null>(null);
const startServerSideSso = useCallback(async () => {
setOauthLoading(true);
@@ -454,6 +503,10 @@ function LoginPageContent() {
}, [params.locale, selectedServer?.id, isMobileHandoff, mobileRedirectUri, mobileState]);
useEffect(() => {
// After signing out, show the login page rather than signing straight
// back in through a provider session that outlived the sign-out (#905).
if (signedOutOnPurpose.current === null) signedOutOnPurpose.current = consumeSignedOut();
if (signedOutOnPurpose.current) return;
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
if (ssoError || isAddAccountMode || isAuthenticated) return;
if (autoSsoTriggered.current) return;
@@ -595,7 +648,67 @@ function LoginPageContent() {
}
};
// Lite on Stalwart: the redirect flow without a server (lib/auth/lite-oauth.ts).
// Discovery needs the account, which decides the provider.
const startLiteOAuth = async (liteServerUrl: string) => {
const account = formData.username.trim();
if (!account) {
inputRef.current?.focus();
inputRef.current?.reportValidity();
return;
}
setOauthLoading(true);
setLiteOAuthFailed(false);
const discovery = await liteDiscoverOAuth(liteServerUrl, account);
if (!discovery) {
setLiteOAuthFailed(true);
setOauthLoading(false);
return;
}
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = generateState();
const redirectUri = getLiteOAuthRedirectUri();
const clientId = getLiteClientId();
sessionStorage.setItem("oauth_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
sessionStorage.setItem("oauth_server_url", liteServerUrl);
sessionStorage.removeItem("oauth_server_id");
if (isAddAccountMode) {
sessionStorage.setItem("oauth_add_account_mode", "true");
}
sessionStorage.setItem("oauth_cookie_slot", useAccountStore.getState().getNextCookieSlot().toString());
saveLiteOAuthFlow({
tokenEndpoint: discovery.metadata.token_endpoint,
clientId,
redirectUri,
persistent: rememberMeEnabled && rememberMe,
revocationEndpoint: discovery.metadata.revocation_endpoint,
});
const authUrl = new URL(discovery.metadata.authorization_endpoint);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
if (discovery.scope) authUrl.searchParams.set("scope", discovery.scope);
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("login_hint", account);
if (isAddAccountMode) {
authUrl.searchParams.set("prompt", "select_account");
}
window.location.href = authUrl.toString();
};
const handleOAuthLogin = async () => {
if (LITE_OAUTH_AVAILABLE) {
await startLiteOAuth(probeTarget);
return;
}
if (!oauthMetadata || !effectiveOauthClientId) return;
// In mobile-handoff mode the client-side PKCE flow doesn't help us:
// tokens would land in sessionStorage on the webmail origin and the
@@ -663,6 +776,17 @@ function LoginPageContent() {
// when the admin hasn't configured a server list.
const effectiveServerUrl = selectedServer?.url
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
// Lite on Stalwart: an account whose domain signs in at an external
// provider has no password Stalwart could check. Asked again here (cached)
// because Enter can beat the debounced lookup; a name without a domain is
// always Stalwart's own.
if (LITE_OAUTH_AVAILABLE && formData.username.includes("@")) {
const discovery = await liteDiscoverOAuth(effectiveServerUrl, formData.username);
if (discovery?.external) {
await startLiteOAuth(effectiveServerUrl);
return;
}
}
// Capture before login() so the isAuthenticated effect can build the
// deep-link fragment with values the user actually typed (formData may
// be cleared by the auth store on success).
@@ -685,7 +809,9 @@ function LoginPageContent() {
);
if (success) {
saveUsername(formData.username);
// Suggested again on this browser only when the user did not decline
// being remembered: on a shared computer the next person sees the list.
if (!rememberMeEnabled || rememberMe) saveUsername(formData.username);
if (isMobileHandoff) {
// The isAuthenticated effect handles the redirect; nothing else to
// do here. Don't push to / - that would race the deep link.
@@ -1185,8 +1311,10 @@ function LoginPageContent() {
</div>
</div>
{/* Password field */}
<div className="space-y-1.5">
{/* Password field. Hidden for an account that signs in at an
external provider (Lite on Stalwart): the submit button is
the SSO button then. */}
<div className={cn("space-y-1.5", liteSsoRequired && "hidden")}>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
{t("password_label")}
</label>
@@ -1198,7 +1326,7 @@ function LoginPageContent() {
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-11 px-3.5 pe-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder={t("password_placeholder")}
required
required={!liteSsoRequired}
autoComplete="current-password"
/>
<button
@@ -1221,7 +1349,7 @@ function LoginPageContent() {
LOGIN_SHOW_TOTP (loginShowTotp) for deployments whose mail
server has no per-account TOTP (auth delegated to an
external directory); server-required TOTP still shows. */}
{!showTotpField ? (
{liteSsoRequired ? null : !showTotpField ? (
loginShowTotp ? (
<button
type="button"
@@ -1287,9 +1415,9 @@ function LoginPageContent() {
<Button
type="submit"
className="w-full h-11 font-medium text-[15px] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"
disabled={isLoading}
disabled={isLoading || (liteSsoRequired && oauthLoading)}
>
{isLoading ? (
{isLoading || (liteSsoRequired && oauthLoading) ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("signing_in")}
@@ -1297,12 +1425,12 @@ function LoginPageContent() {
) : (
<div className="flex items-center gap-2">
<LogIn className="w-4 h-4" />
{t("sign_in")}
{liteSsoRequired ? t("sign_in_sso") : t("sign_in")}
</div>
)}
</Button>
{oauthMetadata && (
{(oauthMetadata || liteSsoOffered) && (
<>
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
@@ -1330,7 +1458,7 @@ function LoginPageContent() {
</>
)}
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
{((oauthEnabled && oauthDiscoveryDone && !oauthMetadata) || liteOAuthFailed) && (
<div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
<AlertCircle className="w-5 h-5" />
@@ -32,7 +32,7 @@ import {
PLUGIN_OAUTH_STORAGE_KEY,
type OAuthCallbackPayload,
} from "@/lib/plugin-oauth";
import { CheckCircle2, XCircle } from "lucide-react";
import { CheckCircle2, XCircle } from "@/components/icons";
import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from '@/components/icons';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
interface Props {
+4 -2
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
import { Save, Loader2, RotateCcw, Sparkles } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
@@ -273,13 +273,15 @@ export function AuthTab() {
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
<Toggle label="Allow private OAuth endpoints" description="Permit discovery to resolve to RFC-1918 / loopback hosts. Enable only for split-DNS deployments where the mail server's public hostname resolves to an internal IP." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
<Toggle label="Allow private OAuth endpoints" description="Permit discovered endpoints on other hosts to resolve to RFC-1918 / loopback addresses. Endpoints on the issuer's own host are always allowed, so split-DNS setups rarely need this." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
</Section>
<Section title="Single Sign-On">
<Toggle label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<Toggle label="End provider session on sign-out" description="Signing out of an SSO account also signs out of the identity provider, when it advertises an end_session_endpoint. Turn off if the provider is shared with apps that should stay signed in." configKey="oauthEndSession" value={currentValue('oauthEndSession') as boolean} source={config.oauthEndSession?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="Post-logout redirect URI" description="Where the provider sends users after signing them out. Register it with the provider as a post-logout redirect URI first, or it will refuse the logout. Leave blank to stay on the provider's signed-out page." configKey="oauthPostLogoutRedirectUri" value={currentValue('oauthPostLogoutRedirectUri') as string} source={config.oauthPostLogoutRedirectUri?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/en/login" />
</Section>
<Section title="Admin Dashboard">
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from '@/components/icons';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import {
BRANDING_OVERRIDE_KEYS,
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { AlertTriangle } from '@/components/icons';
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
import type { AuditEntry } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { RefreshCw } from 'lucide-react';
import { RefreshCw } from '@/components/icons';
import type { AuditEntry } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
+1 -1
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle, ArrowUpCircle } from 'lucide-react';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle, ArrowUpCircle } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from '@/components/icons';
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
+6 -5
View File
@@ -1,8 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, Loader2, Lock, Plus, Trash2, ArrowUp, ArrowDown } from 'lucide-react';
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
import { Save, Loader2, Lock, Plus, Trash2, ArrowUp, ArrowDown } from '@/components/icons';
import { iconForName } from '@/components/icons';
import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } from '@/lib/admin/types';
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
@@ -57,6 +57,7 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
{ key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean' },
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
];
@@ -455,7 +456,7 @@ export function PolicyTab() {
)}
{defaultSidebarApps.map((app, index) => {
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
const AppIcon = iconForName(app.icon);
const urlInvalid = app.url.trim().length > 0 && !isValidDefaultAppUrl(app.url);
return (
<div key={app.id} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
@@ -493,7 +494,7 @@ export function PolicyTab() {
<div className="sm:col-span-3">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
Icon (
<a href="https://lucide.dev/icons" target="_blank" rel="noopener noreferrer" className="underline hover:text-foreground">Lucide</a>
<a href="https://tabler.io/icons" target="_blank" rel="noopener noreferrer" className="underline hover:text-foreground">Tabler</a>
)
</label>
<div className="flex items-center gap-2">
@@ -504,7 +505,7 @@ export function PolicyTab() {
maxLength={64}
value={app.icon}
onChange={(e) => updateDefaultApp(index, { icon: e.target.value })}
placeholder="Globe"
placeholder="world"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<span
+8 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, RotateCcw, Loader2 } from 'lucide-react';
import { Save, RotateCcw, Loader2 } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { JmapServersSection } from './_jmap-servers-section';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
@@ -128,6 +128,13 @@ export function SettingsTab() {
<ToggleSetting label="Search Engine Indexing" description="Allow search engines to index this webmail. Off (the default) sends noindex/nofollow in the page head, recommended for private deployments." configKey="searchEngineIndexing" value={currentValue('searchEngineIndexing') as boolean} source={config.searchEngineIndexing?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
<SettingsSection title="Login Page">
<ToggleSetting label="Show Version" description="Show the webmail version in the login page footer. Turn off to keep the exact version hidden from visitors who are not signed in." configKey="loginShowVersion" value={currentValue('loginShowVersion') as boolean} source={config.loginShowVersion?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Show 2FA Code Option" description="Show the &quot;I have a 2FA code&quot; toggle on the login form. Turn off when two-factor authentication is handled by an external identity provider. Accounts that require a code still get the field." configKey="loginShowTotp" value={currentValue('loginShowTotp') as boolean} source={config.loginShowTotp?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Show Heading" description="Show the application name below the login logo" configKey="loginShowHeading" value={currentValue('loginShowHeading') as boolean} source={config.loginShowHeading?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Show Subtitle" description="Show the subtitle below the login heading" configKey="loginShowSubtitle" value={currentValue('loginShowSubtitle') as boolean} source={config.loginShowSubtitle?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
<SettingsSection title="JMAP Servers (multi-server)">
<ToggleSetting
label="Auto-pick server by email domain"
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react';
import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
interface TelemetryStatus {
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from '@/components/icons';
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
+1 -1
View File
@@ -8,7 +8,7 @@ import {
AlertTriangle,
ShieldAlert,
ExternalLink,
} from 'lucide-react';
} from '@/components/icons';
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
import { apiFetch } from '@/lib/browser-navigation';
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
+1 -1
View File
@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Lock } from 'lucide-react';
import { Lock } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
export default function ChangePasswordPage() {
+6 -2
View File
@@ -24,7 +24,7 @@ import {
Store,
Menu,
X,
} from 'lucide-react';
} from '@/components/icons';
import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store';
@@ -159,7 +159,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return;
}
router.replace('/admin/login');
// Carry the page that was asked for through the login, so a connector
// link to a panel (/connector/admin_extension?slug=x) does not dump
// the admin on the dashboard after signing in.
const next = encodeURIComponent(`${pathname}${window.location.search}`);
router.replace(`/admin/login?next=${next}`);
} catch (err) {
if (cancelled) return;
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
+15 -1
View File
@@ -2,10 +2,12 @@
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { Shield } from 'lucide-react';
import { Shield } from '@/components/icons';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import { safeAdminNext } from '@/lib/connector/admin-next';
import { isAdminTab, useAdminTabStore } from '@/stores/admin-tab-store';
export default function AdminLoginPage() {
const router = useRouter();
@@ -35,6 +37,18 @@ export default function AdminLoginPage() {
return;
}
// A connector link to an admin panel parks its destination in ?next=
// so logging in lands on the panel that was clicked, not the dashboard.
// safeAdminNext() is the gate: it is the only path here that comes out
// of a URL rather than out of a fixed template.
const next = safeAdminNext(new URLSearchParams(window.location.search).get('next'));
if (next) {
const [path, query] = next.split('?', 2);
const tab = new URLSearchParams(query ?? '').get('tab');
if (tab && isAdminTab(tab)) useAdminTabStore.getState().setActiveTab(tab);
router.push(path);
return;
}
router.push('/admin');
} catch {
setError('Network error. Please try again.');
+1 -1
View File
@@ -20,7 +20,7 @@ import {
FileCode,
ChevronDown,
ChevronUp,
} from 'lucide-react';
} from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
+27
View File
@@ -0,0 +1,27 @@
import { ConnectorClient } from '@/components/connector/connector-client';
import { TARGET_NAMES } from '@/lib/connector/registry';
import { IS_LITE } from '@/lib/lite';
// Where a connector link lands: /connector/<target>?<params>, resolved to a
// real path inside the app by lib/connector/registry.ts.
//
// Outside [locale], like /protocol and /admin, so a published link never
// carries a locale it has no business choosing. Next matches the static
// `connector` segment before the `[locale]` one, and proxy.ts keeps next-intl
// from rewriting it.
//
// The target segment is all the server sees. The parameters are read from the
// address bar in the client, which is also what makes this work in the static
// Lite export, where there is no server to read them.
export const generateStaticParams = IS_LITE
? () => TARGET_NAMES.map((target) => ({ target }))
: undefined;
export default async function ConnectorRoute({
params,
}: {
params: Promise<{ target: string }>;
}) {
const { target } = await params;
return <ConnectorClient target={target} />;
}
+13 -2
View File
@@ -1,6 +1,6 @@
import type { Metadata, Viewport } from "next";
import { getLocaleDirection } from "@/i18n/direction";
import { Geist, Geist_Mono } from "next/font/google";
import { Geist, Geist_Mono, Hanken_Grotesk } from "next/font/google";
import { headers } from "next/headers";
import { getLocale, getTranslations } from "next-intl/server";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
@@ -40,6 +40,17 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
// The "Flat fields" theme's family. Not preloaded and no metric-adjusted
// fallback: the file is only fetched once that theme renders text in it, and
// scripts Hanken does not cover fall through to the theme's system stack.
const hankenGrotesk = Hanken_Grotesk({
variable: "--font-hanken",
subsets: ["latin", "latin-ext"],
display: "swap",
preload: false,
adjustFontFallback: false,
});
// Resolve a branding value for the requesting host: per-domain override first,
// then the global admin/env/default value (same precedence as app/manifest.ts).
async function brandedValue(key: BrandingOverrideKey, fallback: string): Promise<string> {
@@ -305,7 +316,7 @@ export default async function RootLayout({
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} ${hankenGrotesk.variable} antialiased`}
>
<ServiceWorkerRegistration />
{!themeColorConfigured && <ThemeColorSync />}
+7 -4
View File
@@ -1,9 +1,10 @@
'use client';
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from '@/components/icons';
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
import { takeSetupTokenFromUrl } from '@/lib/setup/url-token';
type State = 'bootstrap' | 'configured' | 'env-managed';
@@ -93,7 +94,7 @@ const STEPS = [
export default function SetupWizardPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [tokenFromUrl, setTokenFromUrl] = useState('');
const [bootstrapping, setBootstrapping] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -110,6 +111,7 @@ export default function SetupWizardPage() {
const [insecureAcknowledged, setInsecureAcknowledged] = useState(false);
useEffect(() => {
setInsecureContext(detectInsecureContext());
setTokenFromUrl(takeSetupTokenFromUrl(window.location, window.history));
}, []);
// ─── Initial status load ────────────────────────────────────────────────
@@ -222,7 +224,7 @@ export default function SetupWizardPage() {
{!authenticated ? (
<WelcomeStep
tokenFromUrl={searchParams.get('token') ?? ''}
tokenFromUrl={tokenFromUrl}
onSubmit={async (t) => {
try {
await submitToken(t);
@@ -500,6 +502,7 @@ function WelcomeStep({ tokenFromUrl, onSubmit }: { tokenFromUrl: string; onSubmi
// Auto-submit if token came in via URL.
useEffect(() => {
if (tokenFromUrl && !submitting) {
setToken(tokenFromUrl);
setSubmitting(true);
onSubmit(tokenFromUrl).finally(() => setSubmitting(false));
}
+5
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
@@ -18,6 +19,10 @@ import { DisallowedUrlError } from '@/lib/security/url-guard';
* methods under the `x:` namespace on the same endpoint.
*/
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
// Registered unconditionally by Next, so the switch lives here: when the
// passthrough (or Stalwart features as a whole) is disabled the route
// does not exist as far as callers are concerned. Checked before the
Binary file not shown.
+6 -8
View File
@@ -20,7 +20,7 @@ import {
} from '@/lib/admin/csp-frame-origins';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
import { sanitizeThemeCSS } from '@/lib/theme-loader';
import { configManager } from '@/lib/admin/config-manager';
async function getDirectoryUrl(): Promise<string> {
@@ -202,14 +202,12 @@ export async function POST(request: NextRequest) {
let css = await cssFile.async('string');
// Validate and sanitize CSS
// Sanitize unconditionally: the safety check is advisory, and the
// sanitizer is also what drops rules outside :root and .dark.
const warnings: string[] = [];
const safety = validateThemeCSSSafety(css);
if (!safety.valid) {
const sanitized = sanitizeThemeCSS(css);
css = sanitized.css;
warnings.push(...sanitized.warnings);
}
const sanitized = sanitizeThemeCSS(css);
css = sanitized.css;
warnings.push(...sanitized.warnings);
const existingTheme = await getTheme(resolvedId);
const isUpdate = existingTheme !== null;
+22 -3
View File
@@ -3,15 +3,34 @@ import { configManager } from '@/lib/admin/config-manager';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import type { SettingsPolicy } from '@/lib/admin/types';
import { POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types';
import { canSeeInstanceDetails } from '@/lib/auth/instance-details';
/**
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this)
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this;
* visitors who are not signed in get the subset the login page needs)
*/
export async function GET() {
export async function GET(request: NextRequest) {
try {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
if (!(await canSeeInstanceDetails(request))) {
// The login page needs the theme policy and gates; the plugin lists,
// push relays and sidebar app URLs map the deployment and are only
// used after signing in, when the client fetches the policy again.
return NextResponse.json(
{
...policy,
forceEnabledPlugins: [],
approvedPlugins: [],
pushRelays: [],
pushRelayUrl: '',
pushRelayUrlLocked: false,
defaultSidebarApps: [],
},
{ headers: { 'Cache-Control': 'no-store', [POLICY_SCOPE_HEADER]: 'public' } },
);
}
return NextResponse.json(policy, {
headers: { 'Cache-Control': 'no-store' },
});
+6 -8
View File
@@ -11,7 +11,7 @@ import {
import JSZip from 'jszip';
import { MAX_THEME_SIZE } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
import { sanitizeThemeCSS } from '@/lib/theme-loader';
/**
* GET /api/admin/themes - List all admin-managed themes
@@ -118,14 +118,12 @@ export async function POST(request: NextRequest) {
let css = await cssFile.async('string');
// Validate and sanitize CSS
// Sanitize unconditionally: the safety check is advisory, and the
// sanitizer is also what drops rules outside :root and .dark.
const warnings: string[] = [];
const safety = validateThemeCSSSafety(css);
if (!safety.valid) {
const sanitized = sanitizeThemeCSS(css);
css = sanitized.css;
warnings.push(...sanitized.warnings);
}
const sanitized = sanitizeThemeCSS(css);
css = sanitized.css;
warnings.push(...sanitized.warnings);
const now = new Date().toISOString();
const theme: ServerTheme = {
+45 -22
View File
@@ -7,15 +7,14 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { normalizeJmapServerUrl } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
import { recordLogin } from '@/lib/telemetry/login-tracker';
import {
ImpersonationJwtError,
impersonationReplayCache,
verifyImpersonationJwt,
} from '@/lib/impersonation/jwt';
import { ImpersonationJwtError, verifyImpersonationJwt } from '@/lib/impersonation/jwt';
import { consumeImpersonationJti } from '@/lib/impersonation/replay-store';
import {
readImpersonationConfig,
resolveImpersonationServerUrl,
} from '@/lib/impersonation/master-config';
import { mintImpersonationCredential } from '@/lib/impersonation/app-password';
import { IMPERSONATION_GRANT_COOKIE, sealImpersonationGrant } from '@/lib/impersonation/grant-cookie';
export const runtime = 'nodejs';
@@ -40,8 +39,13 @@ function impersonationCookieOptions() {
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
* master credentials from env, then mints the same session cookies the
* password-login path produces. The browser is redirected to "/?impersonated=1" (see
* ImpersonationReconciler, GH #646) and the
* SPA hydrates as if the user had just logged in with master@target%master.
* ImpersonationReconciler, GH #646) and the SPA hydrates as if the user had
* just logged in to the target mailbox.
*
* The master credential is used here, server-side, only to create an
* expiring app password on the target mailbox. The session cookies carry
* that app password: the browser reads its session back through
* PUT /api/auth/session, and the master password would open every mailbox.
*
* Returns 404 when the feature is not configured so an unconfigured
* deployment does not advertise the endpoint.
@@ -74,7 +78,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
if (!impersonationReplayCache.consume(claims.jti, claims.exp)) {
if (!(await consumeImpersonationJti(claims.jti, claims.exp))) {
logger.warn('Impersonation JWT replay rejected', { jti: claims.jti });
return NextResponse.json({ error: 'Token already used' }, { status: 401 });
}
@@ -92,26 +96,44 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Invalid JMAP server URL' }, { status: 500 });
}
// Stalwart master-user impersonation: username = "<target>%<master>",
// password = <master_password>. Per Stalwart docs:
// Stalwart master-user login ("<target>%<master>", master password) opens
// the target mailbox; use it once to mint the target's own app password.
// https://stalw.art/docs/auth/authorization/administrator/
const impersonatedUsername = `${claims.mailbox}%${config.masterUser}`;
const authHeader = `Basic ${Buffer.from(
`${impersonatedUsername}:${config.masterPassword}`,
).toString('base64')}`;
let credential;
try {
credential = await mintImpersonationCredential({
serverUrl: normalizedServerUrl,
mailbox: claims.mailbox,
masterUser: config.masterUser,
masterPassword: config.masterPassword,
description: `Support session ${claims.jti}`.slice(0, 100),
});
} catch (err) {
logger.error('Impersonation credential could not be created', {
jti: claims.jti,
mailbox: claims.mailbox,
error: err instanceof Error ? err.message : 'Unknown',
});
return NextResponse.json({ error: 'Could not open the mailbox' }, { status: 502 });
}
const cookieStore = await cookies();
const sessionToken = encryptSession(
normalizedServerUrl,
impersonatedUsername,
config.masterPassword,
);
const sessionToken = encryptSession(normalizedServerUrl, claims.mailbox, credential.secret);
cookieStore.set(sessionCookieName(IMPERSONATION_SLOT), sessionToken, impersonationCookieOptions());
setStalwartAuthContextInStore(cookieStore, IMPERSONATION_SLOT, {
serverUrl: normalizedServerUrl,
username: impersonatedUsername,
authHeader,
username: claims.mailbox,
authHeader: `Basic ${Buffer.from(`${claims.mailbox}:${credential.secret}`).toString('base64')}`,
});
cookieStore.set(
IMPERSONATION_GRANT_COOKIE,
sealImpersonationGrant({
serverUrl: normalizedServerUrl,
mailbox: claims.mailbox,
credentialId: credential.id,
}),
impersonationCookieOptions(),
);
// Structured audit log - operators rely on this for security review.
logger.info('Impersonation session granted', {
@@ -121,6 +143,7 @@ export async function GET(request: NextRequest) {
tenant_id: claims.tenant_id,
actor_user_id: claims.actor_user_id,
iss: claims.iss,
expires_at: credential.expiresAt,
ip:
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
@@ -129,7 +152,7 @@ export async function GET(request: NextRequest) {
user_agent: request.headers.get('user-agent'),
});
void recordLogin(impersonatedUsername, normalizedServerUrl);
void recordLogin(claims.mailbox, normalizedServerUrl);
// Use a relative Location header so the browser resolves it against the
// public request URL. NextResponse.redirect(new URL('/', request.url))
+148 -72
View File
@@ -1,99 +1,175 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { buildOAuthParams, getRequiredConfig, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { createPairing } from '@/lib/auth/pairing-store';
import { hasValidPairReauth } from '@/lib/auth/pair-reauth';
import { createPairingCode, isGrantAvailable, stashGrant } from '@/lib/auth/pairing-store';
import { pairingOwner, readPairReauthFromStore, setPairReauthInStore } from '@/lib/auth/pair-reauth';
import { mintGrantWithPassword } from '@/lib/auth/pair-grant';
import { appAcceptsServerUrl } from '@/lib/auth/pair-bundle';
import { beginPairStepUp, pairAttemptKey, settlePairStepUp } from '@/lib/auth/pair-attempts';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
import { configManager } from '@/lib/admin/config-manager';
import { findServerByUrl, parseJmapServers } from '@/lib/admin/jmap-servers';
import { IMPERSONATION_GRANT_COOKIE, openImpersonationGrant } from '@/lib/impersonation/grant-cookie';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
// Desktop side of the cross-device QR login. The caller must be a signed-in
// webmail session (its refresh token lives in the httpOnly jmap_rt cookie). We
// refresh that token to (a) prove the session is live and (b) obtain a fresh
// access token to hand the phone, then stash the bundle under a one-time
// pairing code. The desktop renders the returned code as a QR; the phone
// redeems it at /api/auth/pair/redeem.
// Desktop side of "Link mobile app". Returns a one-time pairing code for the
// QR; the phone redeems it at /api/auth/pair/redeem.
//
// Token sharing note: the phone receives the SAME refresh token as the desktop.
// That is correct for OAuth servers (such as Stalwart in its default config)
// that do not rotate refresh tokens on use. If the server rotates refresh
// tokens, the two devices would fight over the latest token — such deployments
// should disable rotation for this client or use a token-exchange grant.
// Linking provisions a new long-lived login on another device, so it needs a
// step-up first, and the step-up itself produces what the phone receives:
// - with `password` (and `totp`): the current password is checked against
// the mail server and used once to mint the phone's own sign-in (see
// lib/auth/pair-grant.ts);
// - after a fresh IdP login (/api/auth/sso/start purpose=reauth, completed by
// /api/auth/reauth/sso/complete): that login's own tokens.
// Either way the phone never shares the desktop's session, so signing out
// here does not sign the phone out. Within the step-up window a new code can
// be shown without asking again, until one of them is redeemed.
//
// The account is the slot's verified session context: its server URL and user
// never come from the request body.
function parseSlot(raw: unknown): number {
return typeof raw === 'number' && Number.isInteger(raw) && raw >= 0 && raw < MAX_ACCOUNT_SLOTS ? raw : 0;
}
/**
* The webmail's public base (origin + mount path) the desktop shows in the
* QR. Only accepted for the origin this request came from; the phone sends
* its refresh requests there when it needs the token proxy.
*/
function resolveWebmailBase(raw: unknown, request: NextRequest): string | null {
if (typeof raw !== 'string' || !raw || raw.length > 2048) return null;
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
if (url.username || url.password || url.search || url.hash) return null;
const origin = request.headers.get('origin');
if (origin) {
if (url.origin !== origin) return null;
} else {
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
if (!host || url.host !== host) return null;
}
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
}
/** The desktop's OAuth callback, for servers that insist on a registered redirect. */
function resolveRedirectUri(raw: unknown, webmailBase: string): string | undefined {
if (typeof raw !== 'string' || !raw || raw.length > 2048) return undefined;
try {
const url = new URL(raw);
if (url.origin !== new URL(webmailBase).origin || url.search || url.hash) return undefined;
return url.toString();
} catch {
return undefined;
}
}
const STEP_UP_ERRORS = {
invalid_credentials: 401,
totp_required: 401,
server_unreachable: 502,
pairing_unavailable: 502,
} as const;
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
// so a cross-site top-level POST would otherwise reach this handler.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
const cookieStore = await cookies();
try {
// Step-up gate: minting a pairing code grants new-device access, so it
// requires a recent fresh IdP re-authentication (see the reauth SSO flow).
// The client turns this 401 into a re-auth redirect, then retries.
if (!(await hasValidPairReauth())) {
return NextResponse.json({ error: 'reauth_required' }, { status: 401 });
// The step-up proof, the session context and the phone's sealed refresh
// token are all encrypted under SESSION_SECRET.
if (!hasSessionSecret()) {
return NextResponse.json({ error: 'session_secret_required' }, { status: 500 });
}
const body = await request.json().catch(() => ({}));
const slot =
typeof body.slot === 'number' && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS
? body.slot
: 0;
const cookieName = refreshTokenCookieName(slot);
const refreshToken = cookieStore.get(cookieName)?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'Not signed in' }, { status: 401 });
}
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
const tokenEndpoint = await getTokenEndpoint(serverId);
const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken }, serverId);
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.warn('Pair create: refresh failed', { status: tokenResponse.status, error: errorText });
// Stale session — clear the dead cookie so the user is prompted to log
// back in, mirroring the token route's behaviour.
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
const slot = parseSlot(body?.slot);
const webmailBase = resolveWebmailBase(body?.webmail_base, request);
if (!webmailBase) {
return NextResponse.json({ error: 'invalid_request' }, { status: 400 });
}
const tokens = await tokenResponse.json();
if (!tokens.access_token) {
logger.error('Pair create: refresh response missing access_token');
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
const cookieStore = await cookies();
// A support session opens the mailbox with a short-lived app password; it
// must not be able to hand out a lasting login for it.
if (slot === 0 && openImpersonationGrant(cookieStore.get(IMPERSONATION_GRANT_COOKIE)?.value)) {
return NextResponse.json({ error: 'impersonation' }, { status: 403 });
}
// If the server rotated the refresh token, persist the new one back to the
// desktop's cookie so this very session keeps working. The phone will get
// the same (new) token below.
const effectiveRefreshToken = tokens.refresh_token || refreshToken;
if (tokens.refresh_token) {
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
const context = readStalwartAuthContextFromStore(cookieStore, slot);
if (!context) {
return NextResponse.json({ error: 'not_signed_in' }, { status: 401 });
}
const owner = pairingOwner(slot, context.username);
// The app refuses a mail server over plain http. Say so here, before a
// password attempt is spent and before the desktop would report a phone
// that could never connect as signed in.
if (!appAcceptsServerUrl(context.serverUrl)) {
return NextResponse.json({ error: 'insecure_server' }, { status: 400 });
}
const { clientId, serverUrl } = getRequiredConfig(serverId);
let grantId: string;
const password = typeof body?.password === 'string' ? body.password : '';
if (password) {
const serverUrl = context.serverUrl.replace(/\/+$/, '');
// The login name the credential belongs to, when the session context
// was written under an identity address.
const accountName = context.accountName ?? context.username;
const attemptKey = pairAttemptKey(serverUrl, accountName);
if (!beginPairStepUp(attemptKey)) {
return NextResponse.json({ error: 'too_many_attempts' }, { status: 429 });
}
const totp = typeof body?.totp === 'string' && body.totp.trim() ? body.totp.trim() : undefined;
await configManager.ensureLoaded();
const serverId = findServerByUrl(parseJmapServers(configManager.get<unknown>('jmapServers', [])), serverUrl)?.id ?? null;
const { code, expiresIn } = createPairing({
accessToken: tokens.access_token,
refreshToken: effectiveRefreshToken,
expiresIn: tokens.expires_in,
tokenEndpoint,
clientId,
serverUrl,
serverId,
});
const result = await mintGrantWithPassword({
serverUrl,
trusted: await isTrustedJmapServerUrl(serverUrl),
serverId,
username: accountName,
password,
totp,
webmailRedirectUri: resolveRedirectUri(body?.redirect_uri, webmailBase),
});
if (!result.ok) {
const wrong = result.error === 'invalid_credentials' || (result.error === 'totp_required' && !!totp);
settlePairStepUp(attemptKey, wrong ? 'wrong_credential' : 'other');
return NextResponse.json({ error: result.error }, { status: STEP_UP_ERRORS[result.error] });
}
settlePairStepUp(attemptKey, 'success');
grantId = stashGrant(result.grant, owner);
setPairReauthInStore(cookieStore, { grantId, owner });
logger.info('Pair step-up succeeded', { flow: result.grant.flow, method: 'password' });
} else {
const proof = readPairReauthFromStore(cookieStore);
if (!proof || proof.owner !== owner || !isGrantAvailable(proof.grantId, owner)) {
return NextResponse.json({ error: 'reauth_required' }, { status: 401 });
}
grantId = proof.grantId;
}
return NextResponse.json({ pairing_code: code, server_url: serverUrl, expires_in: expiresIn });
const pairing = createPairingCode(grantId, owner, webmailBase);
if (!pairing) {
return NextResponse.json({ error: 'reauth_required' }, { status: 401 });
}
return NextResponse.json(
{ pairing_code: pairing.code, status_id: pairing.statusId, expires_in: pairing.expiresIn },
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (error) {
logger.error('Pair create error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+28 -22
View File
@@ -1,41 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { consumePairing } from '@/lib/auth/pairing-store';
import { redeemPairingCode } from '@/lib/auth/pairing-store';
import { PAIR_TOKEN_PROXY_PATH, buildRedeemBundle } from '@/lib/auth/pair-bundle';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
// Phone side of the cross-device QR login. The app POSTs the pairing code it
// scanned; we hand back the OAuth token bundle the desktop stashed at
// /api/auth/pair/create. The code is the only credential required — it is
// high-entropy, single-use, and expires within ~2 minutes — so this route is
// intentionally unauthenticated (the scanning device has no webmail cookies).
// Phone side of "Link mobile app". The app POSTs the code it scanned and gets
// the sign-in the desktop's step-up minted for it (see /api/auth/pair/create):
// an OAuth bundle, or for servers without OAuth login a username + (app)
// password. The code is the only credential required - it carries 256 bits,
// works once and expires within two minutes - so this route is intentionally
// unauthenticated (the scanning device has no webmail cookies).
//
// Failures say what happened, so the app can tell the user what to do:
// 400 invalid_code (never issued), 410 expired_code, 410 used_code.
const NO_STORE = { 'Cache-Control': 'no-store' };
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
// so a cross-site top-level POST would otherwise reach this handler.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const { pairing_code: pairingCode } = await request.json().catch(() => ({}));
if (!pairingCode || typeof pairingCode !== 'string') {
return NextResponse.json({ error: 'Missing pairing code' }, { status: 400 });
const body = await request.json().catch(() => ({}));
const pairingCode = body?.pairing_code;
if (typeof pairingCode !== 'string' || !/^[0-9a-f]{64}$/.test(pairingCode)) {
return NextResponse.json({ error: 'invalid_code' }, { status: 400, headers: NO_STORE });
}
const tokens = consumePairing(pairingCode);
if (!tokens) {
// Unknown, expired, or already redeemed — do not distinguish.
return NextResponse.json({ error: 'Invalid or expired pairing code' }, { status: 400 });
const result = redeemPairingCode(pairingCode);
if (!result.ok) {
const status = result.reason === 'invalid' ? 400 : 410;
return NextResponse.json({ error: `${result.reason}_code` }, { status, headers: NO_STORE });
}
return NextResponse.json({
flow: 'oauth',
server_url: tokens.serverUrl,
access_token: tokens.accessToken,
...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
...(typeof tokens.expiresIn === 'number' ? { expires_in: tokens.expiresIn } : {}),
token_endpoint: tokens.tokenEndpoint,
client_id: tokens.clientId,
const bundle = buildRedeemBundle(result.grant, result.webmailBase);
logger.info('Pair code redeemed', {
flow: bundle.flow,
proxied: bundle.flow === 'oauth' && bundle.token_endpoint.endsWith(PAIR_TOKEN_PROXY_PATH),
});
return NextResponse.json(bundle, { headers: NO_STORE });
} catch (error) {
logger.error('Pair redeem error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE });
}
}
+13
View File
@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPairingStatus } from '@/lib/auth/pairing-store';
// Lets the desktop replace its QR with "Signed in on your phone" once the
// code was redeemed. Polled with the separate status id pair/create returned,
// so the code itself is never repeated in URLs or access logs. A status id
// reveals nothing but whether its own code was used.
export async function GET(request: NextRequest) {
const id = request.nextUrl.searchParams.get('id') ?? '';
const status = /^[0-9a-f]{32}$/.test(id) ? getPairingStatus(id) : 'unknown';
return NextResponse.json({ status }, { headers: { 'Cache-Control': 'no-store' } });
}
+125
View File
@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { openPhoneRefreshToken, sealPhoneRefreshToken, type SealedRefresh } from '@/lib/auth/pair-bundle';
import { DEFAULT_CLIENT_ID, getClientSecret, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
import { fetchPublicUrl } from '@/lib/security/url-guard';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
// Token endpoint for paired phones whose grant the phone cannot renew itself:
// the client is confidential (its secret stays on this server), or the
// provider's host is one current app builds refuse to talk to. The app posts
// the standard refresh grant here; we renew at the provider that minted the
// grant, with the secret, and hand back a re-sealed refresh token.
//
// Only refresh tokens this webmail sealed for a phone are accepted (see
// lib/auth/pair-bundle.ts), so the route cannot be used to renew arbitrary
// tokens with our client secret. No cookies are involved.
const UPSTREAM_TIMEOUT_MS = 10_000;
const NO_STORE = { 'Cache-Control': 'no-store', Pragma: 'no-cache' };
/**
* Whether the sealed endpoint is still one this webmail is configured for:
* the discovered token endpoint of its server entry (SSO grants), or the
* structured-login endpoint of an admin-configured JMAP server (password
* grants).
*/
async function stillTrusted(sealed: SealedRefresh): Promise<boolean> {
try {
if (await getTokenEndpoint(sealed.serverId, { fallbackClientId: DEFAULT_CLIENT_ID }) === sealed.tokenEndpoint) {
return true;
}
} catch {
// No OAuth configured for it (any more): try the JMAP server below.
}
const base = sealed.tokenEndpoint.replace(/\/auth\/token$/, '');
return base !== sealed.tokenEndpoint && await isTrustedJmapServerUrl(base);
}
function oauthError(error: string, status: number) {
return NextResponse.json({ error }, { status, headers: NO_STORE });
}
export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
let params: URLSearchParams;
try {
params = new URLSearchParams(await request.text());
} catch {
return oauthError('invalid_request', 400);
}
if (params.get('grant_type') !== 'refresh_token') {
return oauthError('unsupported_grant_type', 400);
}
const sealed = openPhoneRefreshToken(params.get('refresh_token') ?? '');
if (!sealed) {
return oauthError('invalid_grant', 400);
}
const upstreamParams = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: sealed.refreshToken,
client_id: sealed.clientId,
});
// The secret is the admin's, for the admin's servers only - as configured
// now, not when the token was sealed: a server removed or moved since then
// gets neither the secret nor the unguarded fetch.
const trusted = sealed.trusted && await stillTrusted(sealed);
const secret = trusted ? getClientSecret(sealed.serverId) : '';
if (secret) upstreamParams.set('client_secret', secret);
let upstream: Response;
try {
const init = {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: upstreamParams.toString(),
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
};
// The body carries the refresh token and the client secret: never follow
// a redirect with it. A user-chosen server goes through the
// rebinding-safe fetch (which never follows redirects either).
upstream = trusted
? await fetch(sealed.tokenEndpoint, { ...init, redirect: 'error' })
: (await fetchPublicUrl(sealed.tokenEndpoint, init)) as unknown as Response;
} catch (error) {
logger.warn('Pair token proxy: provider unreachable', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return oauthError('temporarily_unavailable', 503);
}
if (!upstream.ok) {
// 400/401/403: the grant is dead (expired, revoked, password changed) and
// the app signs the account out. Anything else is an outage it waits out.
const dead = upstream.status === 400 || upstream.status === 401 || upstream.status === 403;
logger.warn('Pair token proxy: refresh rejected', { status: upstream.status });
return oauthError(dead ? 'invalid_grant' : 'temporarily_unavailable', dead ? 400 : 503);
}
let tokens: { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown; token_type?: unknown };
try {
tokens = await upstream.json();
} catch {
return oauthError('temporarily_unavailable', 503);
}
if (typeof tokens.access_token !== 'string' || !tokens.access_token) {
return oauthError('temporarily_unavailable', 503);
}
const refreshToken = typeof tokens.refresh_token === 'string' && tokens.refresh_token
? tokens.refresh_token
: sealed.refreshToken;
return NextResponse.json(
{
access_token: tokens.access_token,
token_type: typeof tokens.token_type === 'string' ? tokens.token_type : 'Bearer',
...(typeof tokens.expires_in === 'number' ? { expires_in: tokens.expires_in } : {}),
refresh_token: sealPhoneRefreshToken({ ...sealed, refreshToken }),
},
{ headers: NO_STORE },
);
}
+88 -14
View File
@@ -2,20 +2,42 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptPayload } from '@/lib/auth/crypto';
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
import { setPairReauth } from '@/lib/auth/pair-reauth';
import { exchangeCodeForTokens, getRequiredConfig, getTokenEndpoint, hasClientSecret } from '@/lib/oauth/token-exchange';
import { pairingOwner, setPairReauthInStore } from '@/lib/auth/pair-reauth';
import { stashGrant } from '@/lib/auth/pairing-store';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
import { JmapAuthVerificationError, verifyJmapIdentity } from '@/lib/auth/verify-jmap-auth';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
// Completes the step-up re-authentication for device pairing. The user was sent
// to the IdP with prompt=login (see /api/auth/sso/start with purpose=reauth);
// here we verify the returned code against the pending state and exchange it to
// confirm a fresh login actually happened, then set the short-lived pairing
// re-auth proof cookie. We deliberately do NOT issue a login session or write
// any refresh-token cookies — the user is already signed in; this only proves
// recency for the pairing action.
// Completes the step-up re-authentication for device pairing. The user was
// sent to the IdP with prompt=login (see /api/auth/sso/start with
// purpose=reauth); here we verify the returned code against the pending state
// and exchange it. That fresh login becomes the paired phone's own grant: it
// is kept server-side (see pairing-store) until a code for it is redeemed, and
// the short-lived pairing proof cookie remembers which grant. We deliberately
// do NOT issue a login session or touch the desktop's refresh-token cookies -
// the user is already signed in, and the phone must not share that session.
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000;
/** Clock skew allowed between this server and the identity provider. */
const AUTH_TIME_SKEW_MS = 2 * 60 * 1000;
/**
* The `auth_time` claim of an ID token received straight from the token
* endpoint (so its signature need not be checked here), or null.
*/
function idTokenAuthTime(idToken: string | undefined): number | null {
if (!idToken) return null;
try {
const payload = JSON.parse(Buffer.from(idToken.split('.')[1] ?? '', 'base64url').toString('utf8'));
return typeof payload?.auth_time === 'number' ? payload.auth_time : null;
} catch {
return null;
}
}
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
@@ -56,16 +78,68 @@ export async function POST(request: NextRequest) {
const codeVerifier = pending.code_verifier as string;
const redirectUri = pending.redirect_uri as string;
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
const slot = typeof pending.slot === 'number' && Number.isInteger(pending.slot)
&& pending.slot >= 0 && pending.slot < MAX_ACCOUNT_SLOTS ? pending.slot : 0;
if (!codeVerifier || !redirectUri) {
return NextResponse.json({ error: 'Invalid re-auth session data' }, { status: 400 });
}
// A successful exchange proves the user just authenticated at the IdP (the
// freshness is enforced by prompt=login on the authorize request). We don't
// keep the resulting tokens.
await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
const context = readStalwartAuthContextFromStore(cookieStore, slot);
if (!context) {
return NextResponse.json({ error: 'not_signed_in' }, { status: 401 });
}
await setPairReauth();
// A successful exchange proves the user just authenticated at the IdP (the
// freshness is asked for with prompt=login on the authorize request).
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
// An IdP that ignores prompt=login answers from its existing session;
// when its ID token says the login is older than this step-up, it was
// no step-up at all. (No auth_time: nothing to check.)
const authTime = idTokenAuthTime(tokens.id_token);
if (authTime !== null && authTime * 1000 < createdAt - AUTH_TIME_SKEW_MS) {
logger.warn('Reauth complete: the provider did not ask for a fresh login');
return NextResponse.json({ error: 'reauth_not_fresh' }, { status: 401 });
}
// ...but not as whom. The phone must get the account being paired, not
// whichever account the IdP prompt was answered with.
const serverUrl = context.serverUrl.replace(/\/+$/, '');
try {
await verifyJmapIdentity(serverUrl, `Bearer ${tokens.access_token}`, context.username, {
trusted: await isTrustedJmapServerUrl(serverUrl),
});
} catch (error) {
if (error instanceof JmapAuthVerificationError && (error.status === 401 || error.status === 403)) {
logger.warn('Reauth complete: fresh login is a different account');
return NextResponse.json({ error: 'account_mismatch' }, { status: 403 });
}
throw error;
}
// Without a refresh token the phone would be signed out at the first
// access-token expiry; better to say so now.
if (!tokens.refresh_token) {
logger.warn('Reauth complete: provider issued no refresh token');
return NextResponse.json({ error: 'no_refresh_token' }, { status: 502 });
}
const owner = pairingOwner(slot, context.username);
const grantId = stashGrant({
flow: 'oauth',
serverUrl,
serverId: pendingServerId,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
issuedAt: Date.now(),
tokenEndpoint: await getTokenEndpoint(pendingServerId),
clientId: getRequiredConfig(pendingServerId).clientId,
confidential: hasClientSecret(pendingServerId),
trusted: true,
}, owner);
setPairReauthInStore(cookieStore, { grantId, owner });
logger.info('Pair step-up succeeded', { flow: 'oauth', method: 'sso' });
return NextResponse.json({ ok: true });
} catch (error) {
cookieStore.delete(SSO_PENDING_COOKIE);
+58 -5
View File
@@ -15,6 +15,12 @@ import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { insecureCookieHint, verificationFailureBody } from '@/lib/auth/verification-failure';
import { readImpersonationConfig } from '@/lib/impersonation/master-config';
import { revokeImpersonationCredential } from '@/lib/impersonation/app-password';
import { IMPERSONATION_GRANT_COOKIE, openImpersonationGrant } from '@/lib/impersonation/grant-cookie';
import { revokeWopiTokens } from '@/lib/wopi/revocation';
import { clearPairReauthInStore } from '@/lib/auth/pair-reauth';
function sessionCookieOptions() {
return {
@@ -23,6 +29,40 @@ function sessionCookieOptions() {
};
}
/**
* A session sealed around the impersonation master password. Handoffs made
* before impersonation switched to per-mailbox app passwords stored it in
* the session cookie; it opens every mailbox, so it is never handed back to
* a browser, whichever path stored it.
*/
function holdsMasterPassword(credentials: { password: string }): boolean {
const config = readImpersonationConfig();
return !!config && credentials.password === config.masterPassword;
}
async function revokeImpersonationGrant(
cookieStore: Awaited<ReturnType<typeof cookies>>,
): Promise<void> {
const grant = openImpersonationGrant(cookieStore.get(IMPERSONATION_GRANT_COOKIE)?.value);
cookieStore.delete(IMPERSONATION_GRANT_COOKIE);
const config = readImpersonationConfig();
if (!grant || !config) return;
try {
await revokeImpersonationCredential({
serverUrl: grant.serverUrl,
mailbox: grant.mailbox,
masterUser: config.masterUser,
masterPassword: config.masterPassword,
id: grant.credentialId,
});
} catch (error) {
// The app password still expires on its own; sign-out must not fail.
logger.warn('Impersonation credential revocation failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0;
@@ -36,6 +76,8 @@ export async function POST(request: NextRequest) {
// so a cross-site top-level POST would otherwise reach this handler.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
let upstreamUrl = '';
let upstreamTrusted = false;
try {
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
const oauthOnly = configManager.get<boolean>('oauthOnly', false);
@@ -63,8 +105,6 @@ export async function POST(request: NextRequest) {
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
let upstreamUrl: string;
let upstreamTrusted: boolean;
if (trustedUrl) {
upstreamUrl = trustedUrl;
upstreamTrusted = true;
@@ -102,10 +142,15 @@ export async function POST(request: NextRequest) {
void recordLogin(username, normalizedServerUrl);
return NextResponse.json({ ok: true });
const warning = insecureCookieHint(request);
if (warning) logger.warn(`session: ${warning}`);
return NextResponse.json(warning ? { ok: true, warning } : { ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
return NextResponse.json(
verificationFailureBody('session', error, upstreamUrl, upstreamTrusted),
{ status: error.status },
);
}
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
@@ -175,7 +220,7 @@ export async function PUT(request: NextRequest) {
}
const credentials = decryptSession(token);
if (!credentials) {
if (!credentials || holdsMasterPassword(credentials)) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
@@ -204,6 +249,8 @@ export async function DELETE(request: NextRequest) {
try {
const cookieStore = await cookies();
const all = request.nextUrl.searchParams.get('all') === 'true';
// Office editors opened from here stop working with the session.
await revokeWopiTokens(cookieStore, all ? 'all' : getSlot(request));
if (all) {
// Delete all session cookies across every slot.
@@ -211,10 +258,16 @@ export async function DELETE(request: NextRequest) {
cookieStore.delete(sessionCookieName(i));
clearStalwartAuthContextInStore(cookieStore, i);
}
await revokeImpersonationGrant(cookieStore);
// A phone that already paired keeps its own sign-in; a QR still on
// screen stops working.
clearPairReauthInStore(cookieStore);
} else {
const slot = getSlot(request);
cookieStore.delete(sessionCookieName(slot));
clearStalwartAuthContextInStore(cookieStore, slot);
if (slot === 0) await revokeImpersonationGrant(cookieStore);
clearPairReauthInStore(cookieStore, slot);
}
return NextResponse.json({ ok: true });
+50 -8
View File
@@ -6,14 +6,31 @@ import {
exchangeCodeForTokens,
getRequiredConfig,
getTokenEndpoint,
hasClientSecret,
} from '@/lib/oauth/token-exchange';
import { buildRedeemBundle } from '@/lib/auth/pair-bundle';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { storeIdToken } from '@/lib/oauth/end-session';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
/**
* The webmail's public base (origin + mount path) from the callback URL the
* flow started with (`<base>/<locale>/auth/callback`, same-origin checked at
* /sso/start): where a phone renews through the token proxy.
*/
function webmailBaseFromCallback(redirectUri: string): string | null {
try {
const url = new URL(redirectUri);
return `${url.origin}${url.pathname.replace(/\/[^/]+\/auth\/callback\/?$/, '').replace(/\/+$/, '')}`;
} catch {
return null;
}
}
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
// so a cross-site top-level POST would otherwise reach this handler.
@@ -62,6 +79,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'SSO session expired. Please try again.' }, { status: 400 });
}
// A step-up for device pairing completes at /api/auth/reauth/sso/complete
// only; here it would sign this browser in as whoever answered the prompt.
if (pending.purpose === 'reauth') {
logger.warn('SSO complete: refused a pairing re-auth session');
cookieStore.delete(SSO_PENDING_COOKIE);
return NextResponse.json({ error: 'Not a login session' }, { status: 400 });
}
const codeVerifier = pending.code_verifier as string;
const redirectUri = pending.redirect_uri as string;
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
@@ -94,6 +119,7 @@ export async function POST(request: NextRequest) {
} else {
cookieStore.delete(serverCookieName);
}
storeIdToken(cookieStore, slot, tokens.id_token, request.nextUrl.basePath, [tokens.refresh_token, pendingServerId]);
}
// Delete pending cookie
@@ -103,16 +129,32 @@ export async function POST(request: NextRequest) {
// The mobile client needs the bits it can't re-derive: the refresh
// token, the token endpoint it should hit to refresh later, and the
// client_id the IdP expects on that refresh call. The server URL is
// returned so the app knows which JMAP host to connect to.
// returned so the app knows which JMAP host to connect to. A phone
// cannot renew with a confidential client's secret, and current app
// builds refuse a provider on another host, so those renew through
// this webmail - the same bundle a paired phone gets.
const { clientId, serverUrl } = getRequiredConfig(pendingServerId);
const tokenEndpoint = await getTokenEndpoint(pendingServerId);
const bundle = buildRedeemBundle({
flow: 'oauth',
serverUrl,
serverId: pendingServerId,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
issuedAt: Date.now(),
tokenEndpoint: await getTokenEndpoint(pendingServerId),
clientId,
confidential: hasClientSecret(pendingServerId),
trusted: true,
}, webmailBaseFromCallback(redirectUri));
if (bundle.flow !== 'oauth') throw new Error('unexpected bundle');
return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
refresh_token: tokens.refresh_token,
token_endpoint: tokenEndpoint,
client_id: clientId,
server_url: serverUrl,
access_token: bundle.access_token,
expires_in: bundle.expires_in,
refresh_token: bundle.refresh_token,
token_endpoint: bundle.token_endpoint,
client_id: bundle.client_id,
server_url: bundle.server_url,
mobile_redirect_uri: mobileRedirectUri,
mobile_state: mobileState,
});
+32 -6
View File
@@ -5,7 +5,8 @@ import { encryptPayload } from '@/lib/auth/crypto';
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
import { getRequiredConfig, getDiscoveryValidator } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { getOauthScopes } from '@/lib/oauth/tokens';
import { getOauthScopes, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { configManager } from '@/lib/admin/config-manager';
@@ -20,6 +21,19 @@ const SSO_PENDING_MAX_AGE = 300; // 5 minutes
// caller-controlled URL the attacker chose.
const MOBILE_REDIRECT_SCHEME = 'bulwarkmobile://';
/**
* A re-auth mints the grant a paired phone keeps using. Providers such as
* Keycloak tie ordinary refresh tokens to the browser's login session, so
* signing out on the desktop (RP-initiated logout) would sign the phone out
* too; an `offline_access` token outlives that session. Only asked for when
* the provider advertises it, as an unknown scope can fail the request.
*/
function reauthScopes(scopes: string, isReauth: boolean, supported: string[] | undefined): string {
if (!isReauth || !supported?.includes('offline_access')) return scopes;
const list = scopes.split(/\s+/).filter(Boolean);
return list.includes('offline_access') ? scopes : [...list, 'offline_access'].join(' ');
}
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
// so a cross-site top-level POST would otherwise reach this handler.
@@ -37,6 +51,7 @@ export async function POST(request: NextRequest) {
mobile_redirect_uri: rawMobileRedirectUri,
mobile_state: rawMobileState,
purpose: rawPurpose,
slot: rawSlot,
} = await request.json();
// `reauth` drives the step-up flow for device pairing: it forces a fresh
@@ -58,7 +73,18 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid mobile_redirect_uri' }, { status: 400 });
}
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
const cookieStore = await cookies();
// A re-auth must hit the IdP of the account being paired. The client
// doesn't know that account's server id, so read it from the slot's
// server cookie like pair/create does. Without it, deployments that only
// configure OAuth per server (no global client/server) fail discovery.
const reauthSlot =
typeof rawSlot === 'number' && Number.isInteger(rawSlot) && rawSlot >= 0 && rawSlot < MAX_ACCOUNT_SLOTS ? rawSlot : 0;
let serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
if (!serverId && isReauth) {
serverId = cookieStore.get(refreshTokenServerCookieName(reauthSlot))?.value || null;
}
// Validate redirect_uri origin matches the request origin to prevent open redirects
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
@@ -73,7 +99,7 @@ export async function POST(request: NextRequest) {
}
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator(discoveryUrl) });
if (!metadata?.authorization_endpoint) {
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
@@ -97,11 +123,11 @@ export async function POST(request: NextRequest) {
...(serverId ? { server_id: serverId } : {}),
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
...(mobileState ? { mobile_state: mobileState } : {}),
...(isReauth ? { purpose: 'reauth' } : {}),
// The re-auth completes into a grant for the phone paired to this slot.
...(isReauth ? { purpose: 'reauth', slot: reauthSlot } : {}),
};
const encrypted = encryptPayload(pendingData, 'sso-pending');
const cookieStore = await cookies();
const baseCookieOpts = getCookieOptions();
cookieStore.set(SSO_PENDING_COOKIE, encrypted, {
...baseCookieOpts,
@@ -117,7 +143,7 @@ export async function POST(request: NextRequest) {
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirect_uri);
authUrl.searchParams.set('scope', getOauthScopes());
authUrl.searchParams.set('scope', reauthScopes(getOauthScopes(), isReauth, metadata.scopes_supported));
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
+13 -6
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapIdentity } from '@/lib/auth/verify-jmap-auth';
import { JmapAuthVerificationError, resolveJmapIdentity } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
@@ -8,6 +8,7 @@ import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { insecureCookieHint, verificationFailureBody } from '@/lib/auth/verification-failure';
function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS) {
@@ -26,6 +27,8 @@ export async function POST(request: NextRequest) {
// so a cross-site top-level POST would otherwise reach this handler.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
let upstreamUrl = '';
let upstreamTrusted = false;
try {
const { serverUrl, username, authHeader, slot: bodySlot } = await request.json();
@@ -46,8 +49,6 @@ export async function POST(request: NextRequest) {
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
let upstreamUrl: string;
let upstreamTrusted: boolean;
if (trustedUrl) {
upstreamUrl = trustedUrl;
upstreamTrusted = true;
@@ -70,7 +71,7 @@ export async function POST(request: NextRequest) {
// any user's synced settings. Admin-configured servers are `trusted`,
// which only relaxes the public-address requirement (they may live on a
// private network) - never the credential check.
const normalizedServerUrl = await verifyJmapIdentity(upstreamUrl, authHeader, username, {
const { serverUrl: normalizedServerUrl, accountName } = await resolveJmapIdentity(upstreamUrl, authHeader, username, {
trusted: upstreamTrusted,
});
@@ -78,14 +79,20 @@ export async function POST(request: NextRequest) {
serverUrl: normalizedServerUrl,
username,
authHeader,
...(accountName !== username ? { accountName } : {}),
});
void recordLogin(username, normalizedServerUrl);
return NextResponse.json({ ok: true });
const warning = insecureCookieHint(request);
if (warning) logger.warn(`stalwart-context: ${warning}`);
return NextResponse.json(warning ? { ok: true, warning } : { ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
return NextResponse.json(
verificationFailureBody('stalwart-context', error, upstreamUrl, upstreamTrusted),
{ status: error.status },
);
}
logger.error('Failed to store Stalwart auth context', {
+138 -89
View File
@@ -8,19 +8,84 @@ import {
encodeCachedAccessToken,
decodeCachedAccessToken,
} from '@/lib/oauth/tokens';
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint, DEFAULT_CLIENT_ID } from '@/lib/oauth/token-exchange';
import {
exchangeCodeForTokens,
buildOAuthParams,
getMetadata,
getRequiredConfig,
getTokenEndpoint,
DEFAULT_CLIENT_ID,
} from '@/lib/oauth/token-exchange';
import type { OAuthMetadata } from '@/lib/oauth/discovery';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import {
buildEndSessionUrl,
clearIdToken,
getPostLogoutRedirectUri,
idTokenCookieName,
isEndSessionEnabled,
storeIdToken,
} from '@/lib/oauth/end-session';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0;
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
const slot = parseInt(raw, 10);
if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0;
if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return null;
return slot;
}
function getSlot(request: NextRequest): number {
return parseSlot(request.nextUrl.searchParams.get('slot')) ?? 0;
}
// Sign-out waits for this route, so an unresponsive revocation endpoint must
// not hold it up for long.
const REVOCATION_TIMEOUT_MS = 3000;
async function revokeRefreshToken(token: string, serverId: string | null, metadata: OAuthMetadata | null): Promise<void> {
if (!metadata?.revocation_endpoint) return;
const params = buildOAuthParams({
token,
token_type_hint: 'refresh_token',
}, serverId, { fallbackClientId: DEFAULT_CLIENT_ID });
try {
// Never follow a redirect with the refresh token and client secret in
// the body: the endpoint was validated, its redirect target was not.
const revocationResponse = await fetch(metadata.revocation_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
signal: AbortSignal.timeout(REVOCATION_TIMEOUT_MS),
redirect: 'error',
});
if (!revocationResponse.ok) {
logger.warn('Token revocation returned error', { status: revocationResponse.status });
}
} catch (err) {
logger.error('Token revocation network error', { error: err instanceof Error ? err.message : 'Unknown error' });
}
}
/** The provider logout URL for a slot being signed out, or null when there is none to visit. */
function endSessionUrlFor(metadata: OAuthMetadata | null, serverId: string | null, idToken: string | undefined): string | null {
if (!metadata?.end_session_endpoint || !isEndSessionEnabled()) return null;
let clientId: string;
try {
({ clientId } = getRequiredConfig(serverId));
} catch {
return null;
}
return buildEndSessionUrl({
endpoint: metadata.end_session_endpoint,
clientId,
idToken,
postLogoutRedirectUri: getPostLogoutRedirectUri(),
});
}
type CookieStore = Awaited<ReturnType<typeof cookies>>;
/**
@@ -28,21 +93,23 @@ type CookieStore = Awaited<ReturnType<typeof cookies>>;
*
* Scoped to the token's own lifetime - once it expires the cookie is worthless
* and should not linger. A token too large to store is simply not cached.
* Returns the cookie value written, or null.
*/
function cacheAccessToken(
cookieStore: CookieStore,
slot: number,
accessToken: string,
expiresIn: number,
): void {
): string | null {
const name = accessTokenCookieName(slot);
const value = encodeCachedAccessToken(accessToken, expiresIn);
if (!value) {
// Oversized token: drop any stale entry rather than leaving a mismatch.
cookieStore.delete(name);
return;
return null;
}
cookieStore.set(name, value, { ...getCookieOptions(), maxAge: expiresIn });
return value;
}
export async function POST(request: NextRequest) {
@@ -72,7 +139,8 @@ export async function POST(request: NextRequest) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
}
cacheAccessToken(cookieStore, slot, tokens.access_token, tokens.expires_in || 3600);
const cachedAccessToken = cacheAccessToken(cookieStore, slot, tokens.access_token, tokens.expires_in || 3600);
storeIdToken(cookieStore, slot, tokens.id_token, request.nextUrl.basePath, [tokens.refresh_token, cachedAccessToken, serverId]);
// Persist which server entry minted this refresh token so the PUT/DELETE
// handlers can route the refresh/revocation calls to the right token
// endpoint without the client having to track it across page loads.
@@ -140,6 +208,8 @@ export async function PUT(request: NextRequest) {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
// See revokeRefreshToken: the body holds the refresh token.
redirect: 'error',
});
if (!tokenResponse.ok) {
@@ -153,6 +223,7 @@ export async function PUT(request: NextRequest) {
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
cookieStore.delete(accessTokenCookieName(slot));
clearIdToken(cookieStore, slot, request.nextUrl.basePath);
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
}
return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 });
@@ -170,7 +241,12 @@ export async function PUT(request: NextRequest) {
}
const expiresIn = tokens.expires_in || 3600;
cacheAccessToken(cookieStore, slot, tokens.access_token, expiresIn);
const cachedAccessToken = cacheAccessToken(cookieStore, slot, tokens.access_token, expiresIn);
// Providers may reissue the id token on refresh. Only a slot signed in
// through the provider keeps one; a password login's slot gains none here.
if (typeof tokens.id_token === 'string' && cookieStore.get(idTokenCookieName(slot))) {
storeIdToken(cookieStore, slot, tokens.id_token, request.nextUrl.basePath, [tokens.refresh_token, cachedAccessToken]);
}
return NextResponse.json({
access_token: tokens.access_token,
@@ -182,96 +258,69 @@ export async function PUT(request: NextRequest) {
}
}
/**
* Revoke a slot's refresh token and clear its token cookies. With
* `endSession`, also returns the URL that ends the provider's session, or null
* when the provider has no usable end_session_endpoint.
*/
async function signOutSlot(
cookieStore: CookieStore,
slot: number,
basePath: string | undefined,
endSession: boolean,
): Promise<string | null> {
const cookieName = refreshTokenCookieName(slot);
const refreshToken = cookieStore.get(cookieName)?.value;
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
const idToken = cookieStore.get(idTokenCookieName(slot))?.value;
let endSessionUrl: string | null = null;
if (refreshToken || endSession) {
const metadata = await getMetadata(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }).catch((err) => {
logger.warn('Failed to discover OAuth metadata during logout', {
error: err instanceof Error ? err.message : 'Unknown error',
});
return null;
});
if (refreshToken) await revokeRefreshToken(refreshToken, serverId, metadata);
if (endSession) endSessionUrl = endSessionUrlFor(metadata, serverId, idToken);
}
// Cleared whether or not revocation worked: the browser must not be able
// to resume a session the user ended. Only cookies the browser sent are
// cleared - signing out of every slot would otherwise answer with a
// Set-Cookie header per slot and cookie, too large for some proxies.
for (const name of [cookieName, refreshTokenServerCookieName(slot), accessTokenCookieName(slot)]) {
if (cookieStore.get(name)) cookieStore.delete(name);
}
if (idToken !== undefined) clearIdToken(cookieStore, slot, basePath);
return endSessionUrl;
}
export async function DELETE(request: NextRequest) {
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
// so a cross-site top-level POST would otherwise reach this handler.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const all = request.nextUrl.searchParams.get('all') === 'true';
if (all) {
// Revoke and delete all refresh token cookies across every slot.
const cookieStore = await cookies();
for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
const name = refreshTokenCookieName(i);
const serverCookieName = refreshTokenServerCookieName(i);
const token = cookieStore.get(name)?.value;
const slotServerId = cookieStore.get(serverCookieName)?.value || null;
if (token) {
// Best-effort revocation
try {
const metadata = await getMetadata(slotServerId, { fallbackClientId: DEFAULT_CLIENT_ID }).catch(() => null);
if (metadata?.revocation_endpoint) {
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' }, slotServerId, { fallbackClientId: DEFAULT_CLIENT_ID });
await fetch(metadata.revocation_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
}).catch(() => {});
}
} catch { /* best effort */ }
cookieStore.delete(name);
}
cookieStore.delete(serverCookieName);
cookieStore.delete(accessTokenCookieName(i));
}
return NextResponse.json({ ok: true });
}
const slot = getSlot(request);
const cookieName = refreshTokenCookieName(slot);
const params = request.nextUrl.searchParams;
const basePath = request.nextUrl.basePath;
const cookieStore = await cookies();
const refreshToken = cookieStore.get(cookieName)?.value;
const slotServerId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
const metadata = await getMetadata(slotServerId, { fallbackClientId: DEFAULT_CLIENT_ID }).catch((err) => {
logger.warn('Failed to discover OAuth metadata during logout', {
error: err instanceof Error ? err.message : 'Unknown error',
});
return null;
});
if (refreshToken) {
if (metadata?.revocation_endpoint) {
const params = buildOAuthParams({
token: refreshToken,
token_type_hint: 'refresh_token',
}, slotServerId, { fallbackClientId: DEFAULT_CLIENT_ID });
try {
const revocationResponse = await fetch(metadata.revocation_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!revocationResponse.ok) {
logger.warn('Token revocation returned error', { status: revocationResponse.status });
}
} catch (err) {
logger.error('Token revocation network error', { error: err instanceof Error ? err.message : 'Unknown error' });
}
}
cookieStore.delete(cookieName);
}
cookieStore.delete(refreshTokenServerCookieName(slot));
cookieStore.delete(accessTokenCookieName(slot));
let end_session_url: string | undefined;
if (metadata?.end_session_endpoint) {
try {
const parsed = new URL(metadata.end_session_endpoint);
if (parsed.protocol === 'https:') {
end_session_url = metadata.end_session_endpoint;
} else {
logger.warn('Ignoring non-HTTPS end_session_endpoint', { url: metadata.end_session_endpoint });
}
} catch {
logger.warn('Invalid end_session_endpoint URL', { url: metadata.end_session_endpoint });
}
if (params.get('all') === 'true') {
// One top-level navigation can end only one provider session, so the
// caller names the slot whose provider should be signed out, if any.
const endSessionSlot = parseSlot(params.get('end_session_slot'));
const slots = Array.from({ length: MAX_ACCOUNT_SLOTS }, (_, i) => i);
const urls = await Promise.all(
slots.map((i) => signOutSlot(cookieStore, i, basePath, i === endSessionSlot)),
);
const endSessionUrl = endSessionSlot === null ? null : urls[endSessionSlot];
return NextResponse.json({ ok: true, ...(endSessionUrl && { end_session_url: endSessionUrl }) });
}
return NextResponse.json({ ok: true, ...(end_session_url && { end_session_url }) });
const endSessionUrl = await signOutSlot(cookieStore, getSlot(request), basePath, params.get('end_session') === 'true');
return NextResponse.json({ ok: true, ...(endSessionUrl && { end_session_url: endSessionUrl }) });
} catch (error) {
logger.error('Token revocation error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+44 -129
View File
@@ -5,45 +5,27 @@ import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oaut
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
import { DisallowedUrlError, fetchPublicUrl, isPublicHttpUrl } from '@/lib/security/url-guard';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
import { DEFAULT_CLIENT_ID } from '@/lib/oauth/token-exchange';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login';
/**
* Exchange a password + (optional) TOTP code for OAuth tokens.
*
* Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over
* HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None` and never
* splits the secret on `$`, so any TOTP appended to the password is verified
* verbatim against the password hash and fails. The MFA token must instead be
* supplied as a distinct field through the structured login endpoint.
*
* This route drives that flow server-side (avoiding browser CORS against the
* mail server, same as OAuth discovery):
* 1. POST {serverUrl}/api/auth -> authenticate with a separate `mfaToken`,
* receiving a short-lived authorization `clientCode`.
* 2. POST {serverUrl}/auth/token (grant_type=authorization_code) -> exchange
* the code (with PKCE) for access/refresh tokens.
* Stalwart 0.16+ rejects TOTP over HTTP Basic, so a TOTP login goes through
* its structured login endpoint instead (see stalwartPasswordLogin). This
* route drives that flow server-side (avoiding browser CORS against the mail
* server, same as OAuth discovery) and keeps the refresh token in the slot's
* httpOnly cookie.
*
* Token-based auth also survives TOTP rotation, unlike basic auth which embeds
* the (≈30s) code in every request.
*/
interface LoginResult {
type?: string;
// The response keeps snake_case: only the LoginResponse variant *tags* are
// camelCased server-side, not the struct fields (the request fields are).
client_code?: string;
}
function trimUrl(url: string): string {
return url.replace(/\/+$/, '');
}
async function attemptLogin(
upstreamUrl: string,
username: string,
@@ -54,13 +36,7 @@ async function attemptLogin(
serverId: string | null,
upstreamTrusted: boolean,
): Promise<NextResponse> {
const base = trimUrl(upstreamUrl);
// A user-supplied endpoint (allowCustomJmapEndpoint) must connect through
// the rebinding-safe fetch; admin-configured servers may live on private
// addresses and use the plain one.
const upstreamFetch: typeof fetch = upstreamTrusted
? fetch
: ((input, init) => fetchPublicUrl(String(input), init as Parameters<typeof fetchPublicUrl>[1]) as unknown as Promise<Response>);
const base = upstreamUrl.replace(/\/+$/, '');
// Per-server OAuth credentials override the global ones when the requested
// server entry has its own oauth block configured.
@@ -76,111 +52,50 @@ async function attemptLogin(
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE)
|| '';
// PKCE proves the token exchange originates from the same client that
// initiated the login, so no client secret is required for public clients.
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const result = await stalwartPasswordLogin({
serverUrl: base,
username,
password,
totp,
clientId,
clientSecret,
redirectUri,
trusted: upstreamTrusted,
});
// Step 1: structured login with a separate MFA token.
let login: LoginResult;
try {
const loginResponse = await upstreamFetch(`${base}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'authCode',
accountName: username,
accountSecret: password,
...(totp ? { mfaToken: totp } : {}),
clientId,
redirectUri,
codeChallenge: challenge,
codeChallengeMethod: 'S256',
}),
});
if (!loginResponse.ok) {
const detail = (await loginResponse.text()).substring(0, 500);
logger.warn('TOTP login: /api/auth rejected request', { status: loginResponse.status });
if (!result.ok) {
switch (result.error) {
// A 404 means the server predates the structured login endpoint; let the
// caller fall back to the legacy basic-auth path.
return NextResponse.json(
{ error: loginResponse.status === 404 ? 'login_endpoint_missing' : 'login_failed', detail },
{ status: loginResponse.status === 404 ? 404 : 502 },
);
case 'login_endpoint_missing':
return NextResponse.json({ error: 'login_endpoint_missing', detail: result.detail }, { status: 404 });
case 'login_rejected':
return NextResponse.json({ error: 'login_failed', detail: result.detail }, { status: 502 });
case 'invalid_server_url':
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
case 'login_unreachable':
return NextResponse.json({ error: 'login_unreachable' }, { status: 502 });
case 'totp_required':
return NextResponse.json({ error: 'totp_required' }, { status: 401 });
case 'invalid_credentials':
return NextResponse.json({ error: 'invalid_credentials' }, { status: 401 });
case 'login_failed':
return NextResponse.json({ error: 'login_failed' }, { status: 502 });
case 'token_exchange_failed':
return NextResponse.json(
result.detail !== undefined ? { error: 'token_exchange_failed', detail: result.detail } : { error: 'token_exchange_failed' },
{ status: 502 },
);
}
login = await loginResponse.json();
} catch (err) {
if (err instanceof DisallowedUrlError) {
logger.warn('TOTP login: rejected non-public server URL at connect time');
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
}
logger.warn('TOTP login: /api/auth request failed', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'login_unreachable' }, { status: 502 });
}
switch (login.type) {
case 'authenticated':
break;
case 'mfaRequired':
return NextResponse.json({ error: 'totp_required' }, { status: 401 });
case 'failure':
default:
return NextResponse.json({ error: 'invalid_credentials' }, { status: 401 });
}
if (!login.client_code) {
logger.warn('TOTP login: authenticated response missing client_code');
return NextResponse.json({ error: 'login_failed' }, { status: 502 });
}
// Step 2: exchange the authorization code for tokens.
const tokenParams = new URLSearchParams({
grant_type: 'authorization_code',
code: login.client_code,
client_id: clientId,
redirect_uri: redirectUri,
code_verifier: verifier,
});
// Confidential clients still send their secret; harmless for public clients.
if (clientSecret) tokenParams.set('client_secret', clientSecret);
let tokens: { access_token?: string; expires_in?: number; refresh_token?: string };
try {
const tokenResponse = await upstreamFetch(`${base}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString(),
});
if (!tokenResponse.ok) {
const detail = (await tokenResponse.text()).substring(0, 500);
logger.warn('TOTP login: token exchange failed', { status: tokenResponse.status, detail });
return NextResponse.json({ error: 'token_exchange_failed', detail }, { status: 502 });
}
tokens = await tokenResponse.json();
} catch (err) {
if (err instanceof DisallowedUrlError) {
logger.warn('TOTP login: rejected non-public server URL at connect time');
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
}
logger.warn('TOTP login: token endpoint failed', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'token_exchange_failed' }, { status: 502 });
}
if (!tokens.access_token) {
return NextResponse.json({ error: 'token_exchange_failed', detail: 'Response missing access_token' }, { status: 502 });
}
logger.info('TOTP login succeeded');
void recordLogin(username, base);
return await storeAndRespond(
{ access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token },
slot,
serverId,
);
return await storeAndRespond(result.tokens, slot, serverId);
}
function trimUrl(url: string): string {
return url.replace(/\/+$/, '');
}
export async function POST(request: NextRequest) {
+9
View File
@@ -5,6 +5,8 @@ import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getClientIP } from '@/lib/admin/session';
import { reserveVerifyProbe } from '@/lib/auth/verify-budget';
/**
* Server-side Basic-auth pre-check for the login form (#969).
@@ -75,14 +77,21 @@ export async function POST(request: NextRequest) {
return respond('inconclusive');
}
// Wrong passwords tried from here count against this server's address
// upstream; over budget the browser probes the JMAP server itself.
const refund = reserveVerifyProbe(getClientIP(request));
if (!refund) return respond('inconclusive');
const authHeader = 'Basic ' + Buffer.from(username + ':' + password).toString('base64');
try {
await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
refund();
return respond('ok');
} catch (error) {
if (error instanceof JmapAuthVerificationError && error.upstreamStatus === 401) {
return respond('unauthorized');
}
refund();
logger.debug('Login pre-check inconclusive', {
error: error instanceof Error ? error.message : 'Unknown error',
});
+5
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
@@ -56,6 +57,10 @@ async function probeCalendarHome(
}
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
+5
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
@@ -100,6 +101,10 @@ function firstCalendarId(event: Partial<CalendarEvent>): string | null {
}
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
+6 -2
View File
@@ -53,6 +53,7 @@ export async function GET(request: NextRequest) {
const stalwartJmapPassthroughEnabled =
stalwartFeaturesEnabled && configManager.get<boolean>('stalwartJmapPassthroughEnabled', true);
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
const autoPickByDomain = configManager.get<boolean>('jmapServerAutoPickByDomain', false);
return NextResponse.json(
{
@@ -85,8 +86,11 @@ export async function GET(request: NextRequest) {
loginShowVersion: configManager.get<boolean>('loginShowVersion', true),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
// The domain lists name every organisation served here; the login page
// only needs them to pick the server by the typed address.
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', [])))
.map((server) => (autoPickByDomain ? server : { ...server, domains: [] })),
jmapServerAutoPickByDomain: autoPickByDomain,
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
import { TARGET_NAMES } from '@/lib/connector/registry';
/**
* What a connector asks an instance about itself.
*
* Someone adding this instance at connector.bulwarkmail.org gets a request
* here, from their own browser, straight to their own server, with no
* credentials. It answers two questions: is this really a Bulwark, and which
* connector links can it resolve - so a link to a target this version does not
* have can warn before it opens instead of after.
*
* Unauthenticated on purpose: the browser making the probe has no session
* here, and the answer is narrower than the login page it sits next to. It
* says nothing about accounts, domains or the mail server - only the product,
* its version, the branded app name, and the target names, which are a
* compile-time constant.
*
* `appName` can be a customer's own branding, so the whole endpoint is gated
* on `connectorEnabled` for deployments that would rather disclose nothing.
*
* Bulwark Lite has no route handlers; scripts/lite/postbuild.mjs writes the
* same document to /connector.json, and the connector tries this first and
* falls back to that.
*/
export async function GET() {
await configManager.ensureLoaded();
if (!configManager.get<boolean>('connectorEnabled', true)) {
return new NextResponse(null, { status: 404 });
}
return NextResponse.json(
{
product: 'bulwark-webmail',
appName:
configManager.get<string>('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Bulwark',
version: process.env.NEXT_PUBLIC_APP_VERSION || null,
connectorPath: '/connector',
targets: TARGET_NAMES,
},
{
headers: {
// The probe is cross-origin by definition - it comes from the
// connector's origin, or from any other site someone points at their
// instance. Nothing here is per-user, so there is nothing for a
// wildcard to leak; credentials are never read.
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=300',
},
},
);
}
+13 -5
View File
@@ -2212,14 +2212,18 @@ async function buildMinimalOdf(type: string): Promise<Buffer | null> {
function handleFileNodeGet(args: MethodArgs, callId: string): MethodResult {
const ids = args.ids as string[] | null | undefined;
const list = ids == null ? [...fileNodes] : fileNodes.filter((n) => ids.includes(n.id));
// Like Stalwart, ids:null stops at maxObjectsInGet without saying so (#1069).
const list = ids == null ? fileNodes.slice(0, MAX_OBJECTS_IN_GET) : fileNodes.filter((n) => ids.includes(n.id));
const notFound = ids == null ? [] : ids.filter((id) => !fileNodes.some((n) => n.id === id));
return ['FileNode/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound }, callId];
}
function handleFileNodeQuery(_args: MethodArgs, callId: string): MethodResult {
const ids = fileNodes.filter((n) => n.blobId !== null).map((n) => n.id);
return ['FileNode/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids, total: ids.length, position: 0 }, callId];
function handleFileNodeQuery(args: MethodArgs, callId: string): MethodResult {
const matching = fileNodes.filter((n) => n.blobId !== null).map((n) => n.id);
const position = typeof args.position === 'number' ? args.position : 0;
const limit = typeof args.limit === 'number' ? args.limit : matching.length;
const ids = matching.slice(position, position + limit);
return ['FileNode/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids, total: matching.length, position }, callId];
}
function handleFileNodeSet(args: MethodArgs, callId: string): MethodResult {
@@ -2467,7 +2471,11 @@ function tooLargeFor(method: string, args: MethodArgs, callId: string): MethodRe
// ---------------------------------------------------------------------------
function isDevMockEnabled(): boolean {
return process.env.DEV_MOCK_JMAP === 'true';
// NEXT_PUBLIC_DEV_MOCK_JMAP is inlined at build time from DEV_MOCK_JMAP
// (next.config.ts `env`), so a build made without the mock - every
// release image and tarball - cannot switch it on through the runtime
// environment: the mock accepts any password.
return process.env.NEXT_PUBLIC_DEV_MOCK_JMAP === 'true' && process.env.DEV_MOCK_JMAP === 'true';
}
function getBaseUrl(request: NextRequest): string {
+36 -9
View File
@@ -455,10 +455,7 @@ export async function GET(request: NextRequest) {
const cached = cache.get(normalizedDomain);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return new NextResponse(cached.data, {
headers: {
'Content-Type': cached.contentType,
'Cache-Control': 'public, max-age=1209600', // 2 weeks
},
headers: faviconHeaders(cached.contentType),
});
}
@@ -474,7 +471,15 @@ export async function GET(request: NextRequest) {
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
}
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
// Served inline on the webmail origin, so only raster image types pass:
// an image/svg+xml or text/html favicon opened on its own would run
// script as the webmail.
const contentType = rasterFaviconType(upstream.headers.get('content-type'));
if (!contentType) {
evictNegativeOldest();
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
}
const data = await upstream.arrayBuffer();
// Don't cache empty/tiny responses (likely no real favicon)
@@ -489,10 +494,7 @@ export async function GET(request: NextRequest) {
cache.set(normalizedDomain, { data, contentType, fetchedAt: Date.now() });
return new NextResponse(data, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=1209600',
},
headers: faviconHeaders(contentType),
});
} catch {
return new NextResponse(null, {
@@ -502,6 +504,31 @@ export async function GET(request: NextRequest) {
}
}
const RASTER_FAVICON_TYPES = new Set([
'image/x-icon',
'image/vnd.microsoft.icon',
'image/png',
'image/gif',
'image/jpeg',
'image/webp',
'image/bmp',
]);
/** The upstream type when it is a raster image, else null. */
function rasterFaviconType(header: string | null): string | null {
const type = (header ?? 'image/x-icon').split(';')[0].trim().toLowerCase();
return RASTER_FAVICON_TYPES.has(type) ? type : null;
}
function faviconHeaders(contentType: string): Record<string, string> {
return {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=1209600', // 2 weeks
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; sandbox",
};
}
function evictNegativeOldest() {
if (negativeCache.size < NEGATIVE_CACHE_MAX_SIZE) return;
let oldestKey: string | null = null;
+5
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { DisallowedUrlError, fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard';
@@ -57,6 +58,10 @@ function extractBasicAuth(rawUrl: string): { cleanUrl: string; authHeader: strin
* (GHSA-24w9-8r42-8jwm).
*/
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
+5 -2
View File
@@ -2,6 +2,7 @@ import v8 from 'node:v8';
import { NextResponse } from 'next/server';
import { NextRequest } from 'next/server';
import { logger } from '@/lib/logger';
import { canSeeInstanceDetails } from '@/lib/auth/instance-details';
const MEMORY_WARNING_THRESHOLD = 0.85;
const MEMORY_CRITICAL_THRESHOLD = 0.95;
@@ -40,12 +41,14 @@ interface HealthStatus {
* Health check endpoint for container orchestration
*
* GET /api/health - Liveness probe for container orchestration
* GET /api/health?detailed=true - Diagnostics with advisory memory warnings
* GET /api/health?detailed=true - Diagnostics with advisory memory warnings,
* for signed-in users and admins (versions and memory fingerprint the
* instance); anyone else gets the plain liveness answer
* HEAD /api/health - Lightweight liveness probe (status code only)
*/
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const detailed = searchParams.get('detailed') === 'true';
const detailed = searchParams.get('detailed') === 'true' && await canSeeInstanceDetails(request);
try {
const timestamp = new Date().toISOString();
+5
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { cookies } from 'next/headers';
import { decryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
@@ -58,6 +59,10 @@ export async function GET(request: NextRequest) {
}
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const username = await resolveUsername();
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
+9 -3
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { canSeeInstanceDetails } from '@/lib/auth/instance-details';
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
import { listDevPlugins } from '@/lib/admin/plugin-dev';
import { configManager } from '@/lib/admin/config-manager';
@@ -8,9 +9,14 @@ import { logger } from '@/lib/logger';
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
*
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
* No admin auth required - this is how regular users receive plugins/themes.
* No admin auth required - this is how regular users receive plugins/themes -
* but a session is: the list names the plugins and the hosts they may reach,
* and the client only syncs after signing in.
*/
export async function GET() {
export async function GET(request: NextRequest) {
if (!(await canSeeInstanceDetails(request))) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
try {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
+8 -1
View File
@@ -19,6 +19,12 @@ interface ResolvedTarget {
accountId: string;
/** See StalwartCredentials.trusted - false routes through the guarded fetch. */
trusted: boolean;
/**
* Cookie slot of the login that owns the account. Handed back so a click
* on the notification opens the message in that login: the worker knows
* only the JMAP account id, which can repeat across servers.
*/
slot?: number;
}
// When the SW passes ?accountId=, we need the slot whose JMAP session owns
@@ -54,7 +60,7 @@ async function resolveTargetForAccount(accountId: string): Promise<ResolvedTarge
accountId !== mailAccountId &&
Object.prototype.hasOwnProperty.call(session.accounts ?? {}, accountId);
if (mailAccountId !== accountId && !isSharedAccount) return null;
return { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId, trusted };
return { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId, trusted, slot };
} catch {
return null;
}
@@ -268,6 +274,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
email,
unreadTotal,
...(target.slot !== undefined ? { slot: target.slot } : {}),
}, {
headers: {
// SW already gates on its own logic - don't let push events get
+26 -11
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto';
@@ -66,9 +67,12 @@ function normalizeUrl(url: string): string {
* With multi-account, the requesting account may be on any slot.
* Checks both basic-auth session cookies and stalwart auth context cookies
* (used by OAuth/SSO and TOTP-upgraded sessions).
* Returns true only if a matching cookie is found.
* Returns the account name to key the settings on, or null when no cookie
* matches. That is the cookie's `accountName` when it has one: a Bearer
* login claimed with a bare local part must not share `john`'s settings
* with every other `john` on a multi-domain server.
*/
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
async function verifyIdentity(username: string, serverUrl: string): Promise<string | null> {
const cookieStore = await cookies();
const normalizedServerUrl = normalizeUrl(serverUrl);
@@ -78,19 +82,19 @@ async function verifyIdentity(username: string, serverUrl: string): Promise<bool
if (token) {
const session = decryptSession(token);
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
return true;
return username;
}
}
// Check stalwart auth context cookie (set for all auth modes)
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
return true;
return ctx.accountName ?? username;
}
}
// No matching session found (or no cookies at all) → reject
return false;
return null;
}
export async function GET(request: NextRequest) {
@@ -104,12 +108,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Missing identity headers' }, { status: 400 });
}
if (!(await verifyIdentity(username, serverUrl))) {
const accountName = await verifyIdentity(username, serverUrl);
if (!accountName) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}
try {
const settings = await loadUserSettings(username, serverUrl);
const settings = await loadUserSettings(accountName, serverUrl);
return NextResponse.json({ settings: settings || null });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
@@ -121,6 +126,10 @@ export async function GET(request: NextRequest) {
}
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
if (!isEnabled()) {
return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 });
}
@@ -135,7 +144,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Settings must be an object' }, { status: 400 });
}
if (!(await verifyIdentity(username, serverUrl))) {
const accountName = await verifyIdentity(username, serverUrl);
if (!accountName) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}
@@ -166,7 +176,7 @@ export async function POST(request: NextRequest) {
}
}
await saveUserSettings(username, serverUrl, filteredSettings);
await saveUserSettings(accountName, serverUrl, filteredSettings);
return NextResponse.json({ ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
@@ -178,6 +188,10 @@ export async function POST(request: NextRequest) {
}
export async function DELETE(request: NextRequest) {
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
if (!isEnabled()) {
return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 });
}
@@ -188,11 +202,12 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (!(await verifyIdentity(username, serverUrl))) {
const accountName = await verifyIdentity(username, serverUrl);
if (!accountName) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}
await deleteUserSettings(username, serverUrl);
await deleteUserSettings(accountName, serverUrl);
return NextResponse.json({ ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
+15 -3
View File
@@ -1,5 +1,7 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { checkOnce, freshStatus, loadState } from '@/lib/version-check';
import { configManager } from '@/lib/admin/config-manager';
import { canSeeInstanceDetails } from '@/lib/auth/instance-details';
// Public endpoint that returns the latest cached update status. Fed by the
// background scheduler started in instrumentation.node.ts; in production we
@@ -10,7 +12,7 @@ import { checkOnce, freshStatus, loadState } from '@/lib/version-check';
// version server's overrides take effect on the next page reload instead of
// requiring a dev-server restart. The 5s upstream timeout in fetchStatus
// caps the worst-case latency added to a dev reload.
export async function GET() {
export async function GET(request: NextRequest) {
if (process.env.NODE_ENV === 'development') {
await checkOnce({ reason: 'dev-reload' });
}
@@ -18,9 +20,19 @@ export async function GET() {
const state = await loadState();
// freshStatus drops a status persisted by an earlier build - after an
// upgrade it would still advertise the version we just upgraded from (#913).
let status = freshStatus(state);
// Visitors who are not signed in see the version only where the login
// page shows it (loginShowVersion), and never the advisory text: a
// pending security update tells an attacker which hole is still open.
if (status && !(await canSeeInstanceDetails(request))) {
await configManager.ensureLoaded();
status = configManager.get<boolean>('loginShowVersion', true) ? { ...status, advisory: null } : null;
}
return NextResponse.json(
{
status: freshStatus(state),
status,
lastCheckedAt: state.lastCheckedAt,
lastSuccessAt: state.lastSuccessAt,
},
+5
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
@@ -257,6 +258,10 @@ async function translateLibre(
// ─── Route ────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
// Same session gate as the other authenticated API routes: the plugin's
// api.http.post carries the session cookies, but nothing else should be
// able to relay requests through this deployment to the backends. (#903)
+5
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
@@ -51,6 +52,10 @@ function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string, c
* Overwrite: WebDAV Overwrite header for MOVE/COPY
*/
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
+46 -14
View File
@@ -5,36 +5,65 @@ import { wopiContext } from '@/lib/wopi/request';
/**
* WOPI GetFile / PutFile (#425). Content flows purely over JMAP: GetFile
* streams the node's blob, PutFile uploads the editor's bytes as a new blob
* and points the FileNode at it via `FileNode/set { blobId }`.
* streams the node's blob (or, for a mail attachment, the attachment blob
* itself - #1047), PutFile uploads the editor's bytes as a new blob into the
* user's own account and points the FileNode at it via `FileNode/set { blobId }`.
*/
/**
* GetFile answers on the webmail origin, and the type of the bytes comes
* from the launch request or a user-settable FileNode property. Served
* inline as `text/html` or `image/svg+xml` it would run script as the
* webmail, so the response is always an inert download whatever the
* source claims to be.
*/
const GET_FILE_HEADERS: Record<string, string> = {
'Content-Type': 'application/octet-stream',
'Content-Disposition': 'attachment',
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Cache-Control': 'no-store',
};
/** GET = GetFile */
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> },
) {
// The editor fetches this server-to-server and sends no Sec-Fetch headers.
// A browser navigating to it, or loading it as a script or style, is not
// the editor.
const dest = request.headers.get('sec-fetch-dest');
if (dest !== null && dest !== 'empty') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
try {
const { fileId } = await params;
const auth = await wopiContext(request, fileId);
const { fileId: documentId } = await params;
const auth = await wopiContext(request, documentId);
if (!auth) {
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
}
const node = await getWopiFileNode(auth.ctx, auth.payload.accountId, fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
const { payload } = auth;
let source: { blobId: string; name: string; type: string };
if (payload.kind === 'attachment') {
source = { blobId: payload.fileId, name: payload.name || 'attachment', type: payload.type || '' };
} else {
const node = await getWopiFileNode(auth.ctx, payload.accountId, payload.fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
source = { blobId: node.blobId, name: node.name, type: node.type };
}
const upstream = await downloadFileBlob(
auth.ctx, auth.payload.accountId, node.blobId, node.name, node.type,
auth.ctx, payload.accountId, source.blobId, source.name, source.type,
);
if (!upstream.ok) {
return NextResponse.json({ error: 'Blob download failed' }, { status: 502 });
}
const headers = new Headers();
headers.set('Content-Type', node.type || 'application/octet-stream');
const headers = new Headers(GET_FILE_HEADERS);
const contentLength = upstream.headers.get('Content-Length');
if (contentLength) headers.set('Content-Length', contentLength);
return new NextResponse(upstream.body, { status: 200, headers });
@@ -52,15 +81,16 @@ export async function POST(
{ params }: { params: Promise<{ fileId: string }> },
) {
try {
const { fileId } = await params;
const auth = await wopiContext(request, fileId);
const { fileId: documentId } = await params;
const auth = await wopiContext(request, documentId);
if (!auth) {
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
}
if (!auth.payload.canWrite) {
if (!auth.payload.canWrite || auth.payload.kind === 'attachment') {
return NextResponse.json({ error: 'Read-only token' }, { status: 403 });
}
const fileId = auth.payload.fileId;
const node = await getWopiFileNode(auth.ctx, auth.payload.accountId, fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
@@ -85,7 +115,9 @@ export async function POST(
return NextResponse.json({ error: 'Empty document rejected' }, { status: 409 });
}
const blob = await uploadFileBlob(auth.ctx, auth.payload.accountId, body, node.type);
const blob = await uploadFileBlob(
auth.ctx, auth.payload.uploadAccountId || auth.payload.accountId, body, node.type,
);
const { modified } = await updateFileNodeBlob(
auth.ctx, auth.payload.accountId, fileId, blob.blobId,
);
+25 -5
View File
@@ -18,13 +18,33 @@ export async function GET(
{ params }: { params: Promise<{ fileId: string }> },
) {
try {
const { fileId } = await params;
const auth = await wopiContext(request, fileId);
const { fileId: documentId } = await params;
const auth = await wopiContext(request, documentId);
if (!auth) {
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
}
const node = await getWopiFileNode(auth.ctx, auth.payload.accountId, fileId);
const { payload } = auth;
if (payload.kind === 'attachment') {
// Mail attachments open read-only (#1047): the blob is immutable, so
// its id doubles as the version and there is nothing to save back to.
return NextResponse.json({
BaseFileName: payload.name || 'attachment',
Size: payload.size ?? 0,
OwnerId: payload.username,
UserId: payload.username,
UserFriendlyName: payload.username,
UserCanWrite: false,
ReadOnly: true,
UserCanNotWriteRelative: true,
SupportsUpdate: false,
SupportsLocks: false,
Version: payload.fileId,
PostMessageOrigin: payload.origin,
});
}
const node = await getWopiFileNode(auth.ctx, payload.accountId, payload.fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
@@ -62,8 +82,8 @@ export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> },
) {
const { fileId } = await params;
const auth = await wopiContext(request, fileId);
const { fileId: documentId } = await params;
const auth = await wopiContext(request, documentId);
if (!auth) {
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
}
+70 -28
View File
@@ -1,31 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { fetchJmapSession } from '@/lib/stalwart/jmap-api';
import { getWopiActions, buildWopiActionUrl } from '@/lib/wopi/discovery';
import { getWopiFileNode } from '@/lib/wopi/files';
import { mintWopiToken } from '@/lib/wopi/token';
import { getWopiFileNode, probeBlob } from '@/lib/wopi/files';
import { mintWopiToken, wopiDocumentId, type WopiTokenPayload } from '@/lib/wopi/token';
import { wopiBrowserBinding } from '@/lib/wopi/revocation';
/**
* POST /api/wopi/launch { fileId: string, accountId?: string }
* POST /api/wopi/launch
* { fileId: string, accountId?: string } - a Files node
* { blobId: string, name: string, type?: string, accountId?: string } - a mail attachment
*
* Mints a WOPI access token scoped to one file node and returns the editor
* URL to POST it to (#425). Session-authenticated - this is the only WOPI
* route the browser calls; everything under /api/wopi/files is called by the
* editor server-to-server with the token minted here.
* Mints a WOPI access token scoped to one document and returns the editor
* URL to POST it to (#425). Attachments always open read-only (#1047).
* Session-authenticated - this is the only WOPI route the browser calls;
* everything under /api/wopi/files is called by the editor server-to-server
* with the token minted here.
*/
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = (await request.json().catch(() => null)) as { fileId?: unknown; accountId?: unknown } | null;
const fileId = typeof body?.fileId === 'string' ? body.fileId : '';
if (!fileId) {
return NextResponse.json({ error: 'fileId is required' }, { status: 400 });
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
const str = (key: string): string => (typeof body?.[key] === 'string' ? (body[key] as string) : '');
const fileId = str('fileId');
const blobId = str('blobId');
const attachmentName = str('name').trim();
if (!fileId && !blobId) {
return NextResponse.json({ error: 'fileId or blobId is required' }, { status: 400 });
}
if (blobId && !attachmentName) {
return NextResponse.json({ error: 'name is required for attachments' }, { status: 400 });
}
const actions = await getWopiActions();
@@ -41,25 +57,48 @@ export async function POST(request: NextRequest) {
const trusted = creds.trusted || new URL(serverUrl).origin === request.nextUrl.origin;
const ctx = { serverUrl, authHeader: creds.authHeader, trusted };
let accountId = typeof body?.accountId === 'string' ? body.accountId : '';
let accountId = str('accountId');
const capability = blobId ? 'urn:ietf:params:jmap:mail' : 'urn:ietf:params:jmap:filenode';
// Files need the session even with an explicit account: PutFile uploads
// into the login's own account, which is not the one holding a node
// shared with the user (#1094).
const session = !accountId || !blobId
? await fetchJmapSession(serverUrl, creds.authHeader, { trusted })
: null;
const ownAccountId = session?.primaryAccounts?.[capability] || '';
if (!accountId) {
const session = await fetchJmapSession(serverUrl, creds.authHeader, { trusted });
accountId =
session?.primaryAccounts?.['urn:ietf:params:jmap:filenode'] ||
Object.keys(session?.accounts ?? {})[0] ||
'';
accountId = ownAccountId || Object.keys(session?.accounts ?? {})[0] || '';
}
if (!accountId) {
return NextResponse.json({ error: 'No files account' }, { status: 404 });
return NextResponse.json({ error: blobId ? 'No mail account' : 'No files account' }, { status: 404 });
}
const node = await getWopiFileNode(ctx, accountId, fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
let document: Pick<WopiTokenPayload, 'kind' | 'fileId' | 'name' | 'type' | 'size' | 'uploadAccountId'>;
let name: string;
let canWrite: boolean;
if (blobId) {
const type = str('type');
// Confirms the blob is readable with these credentials before the
// editor is pointed at it, and reads its size for CheckFileInfo.
const probe = await probeBlob(ctx, accountId, blobId, attachmentName, type);
if (!probe) {
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 });
}
const clientSize = typeof body?.size === 'number' && body.size >= 0 ? body.size : 0;
name = attachmentName;
canWrite = false;
document = { kind: 'attachment', fileId: blobId, name, type, size: probe.size ?? clientSize };
} else {
const node = await getWopiFileNode(ctx, accountId, fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
name = node.name;
canWrite = node.myRights ? !!node.myRights.mayModifyContent : true;
document = { kind: 'file', fileId, uploadAccountId: ownAccountId || accountId };
}
const ext = node.name.split('.').pop()?.toLowerCase() || '';
const canWrite = node.myRights ? !!node.myRights.mayModifyContent : true;
const ext = name.split('.').pop()?.toLowerCase() || '';
const urlsrc = (canWrite && actions.edit[ext]) || actions.view[ext] || actions.edit[ext];
if (!urlsrc) {
return NextResponse.json({ error: 'File type not supported by the editor' }, { status: 415 });
@@ -72,17 +111,20 @@ export async function POST(request: NextRequest) {
const hostBase =
configManager.get<string>('wopiHostUrl', '').trim().replace(/\/+$/, '') ||
request.nextUrl.origin;
const wopiSrc = `${hostBase}/api/wopi/files/${encodeURIComponent(fileId)}`;
const { token, expiresAt } = mintWopiToken({
const tokenPayload = {
serverUrl,
authHeader: creds.authHeader,
username: creds.username,
accountId,
fileId,
...document,
canWrite: editable,
origin: request.nextUrl.origin,
});
// Signing out of this slot in this browser revokes the token.
bid: wopiBrowserBinding(await cookies()),
slot: creds.slot,
};
const wopiSrc = `${hostBase}/api/wopi/files/${wopiDocumentId(tokenPayload)}`;
const { token, expiresAt } = mintWopiToken(tokenPayload);
return NextResponse.json({
url: buildWopiActionUrl(urlsrc, wopiSrc),
@@ -1,7 +1,8 @@
"use client";
import { useEffect } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";
import { AlertTriangle, RefreshCw } from "@/components/icons";
import "./globals.css";
/**
* Global error boundary for the root layout.
+11 -11
View File
@@ -887,17 +887,17 @@ option {
/* RTL: mirror directional icons (chevrons/arrows) so prev/next, back/forward,
and panel-collapse affordances point the correct way in right-to-left layouts.
lucide-react emits a `lucide-<name>` class per icon, so we target the
Tabler emits a `tabler-icon-<name>` class per icon, so we target the
directional ones only — vertical chevrons (up/down) are intentionally left. */
[dir="rtl"] .lucide-chevron-left,
[dir="rtl"] .lucide-chevron-right,
[dir="rtl"] .lucide-chevrons-left,
[dir="rtl"] .lucide-chevrons-right,
[dir="rtl"] .lucide-arrow-left,
[dir="rtl"] .lucide-arrow-right,
[dir="rtl"] .lucide-arrow-big-left,
[dir="rtl"] .lucide-arrow-big-right,
[dir="rtl"] .lucide-panel-left,
[dir="rtl"] .lucide-panel-right {
[dir="rtl"] .tabler-icon-chevron-left,
[dir="rtl"] .tabler-icon-chevron-right,
[dir="rtl"] .tabler-icon-chevrons-left,
[dir="rtl"] .tabler-icon-chevrons-right,
[dir="rtl"] .tabler-icon-arrow-left,
[dir="rtl"] .tabler-icon-arrow-right,
[dir="rtl"] .tabler-icon-arrow-big-left,
[dir="rtl"] .tabler-icon-arrow-big-right,
[dir="rtl"] .tabler-icon-layout-sidebar-left,
[dir="rtl"] .tabler-icon-layout-sidebar-right {
transform: scaleX(-1);
}
+3 -3
View File
@@ -9,8 +9,8 @@ import { useConfig } from "@/hooks/use-config";
import { withBasePath } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils";
import { useThemeStore } from "@/stores/theme-store";
import type { LucideIcon } from "lucide-react";
import { Loader2 } from "lucide-react";
import type { AppIcon } from "@/components/icons";
import { Loader2 } from "@/components/icons";
export interface OAuthStatusAction {
label: string;
@@ -19,7 +19,7 @@ export interface OAuthStatusAction {
export interface OAuthStatusCardProps {
/** Icon badge shown below the logo (defaults to a spinner) */
icon?: LucideIcon;
icon?: AppIcon;
/** Tailwind color classes for the icon badge, e.g. "bg-green-500/10 text-green-600" */
iconClassName?: string;
title: string;
@@ -169,4 +169,20 @@ describe('CalendarAgendaView infinite scroll', () => {
const titles = screen.getAllByText(/^Event /).map((el) => el.textContent);
expect(titles).toEqual(['Event sooner', 'Event later']);
});
it('strikes through events the user declined (#1110)', () => {
const invite = (id: string, status: string) => ({
...makeEvent(id, '2026-09-12T09:00:00'),
participants: {
me: { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', participationStatus: status },
},
}) as unknown as CalendarEvent;
renderView({
events: [invite('declined', 'declined'), invite('accepted', 'accepted')],
currentUserEmails: ['me@example.com'],
});
expect(screen.getByText('Event declined')).toHaveClass('line-through');
expect(screen.getByText('Event declined')).toHaveClass('text-muted-foreground');
expect(screen.getByText('Event accepted')).not.toHaveClass('line-through');
});
});
@@ -0,0 +1,93 @@
import { render, fireEvent, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { CalendarMonthView } from '../calendar-month-view';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import type { CalendarEvent, Calendar } from '@/lib/jmap/types';
// #1119: dropping an event on another day of the month grid moves it there
// at the same time of day. An all-day event keeps a full LocalDateTime
// start - Stalwart drops a date-only one and leaves the event without a
// start, so it vanished from the calendar.
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
function makeEvent(id: string, overrides: Partial<CalendarEvent>): CalendarEvent {
return {
id,
'@type': 'Event',
uid: id,
title: 'Event ' + id,
calendarIds: { 'cal-1': true },
...overrides,
} as unknown as CalendarEvent;
}
function renderView(events: CalendarEvent[]) {
return render(
<CalendarMonthView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="month:2026-09-09"
// Monday 28 Sep to Sunday 4 Oct.
rangeStart={new Date(2026, 8, 28)}
rangeEnd={new Date(2026, 9, 4)}
selectedDate={new Date(2026, 8, 30)}
events={events}
calendars={calendars}
onSelectDate={vi.fn()}
onSelectEvent={vi.fn()}
/>,
);
}
function dropOn(dayIndex: number, event: CalendarEvent) {
const cell = document.querySelectorAll<HTMLElement>('[data-week="2026-09-28"] [role="gridcell"]')[dayIndex];
const json = JSON.stringify({ type: 'calendar-event', eventId: event.id, originalStart: event.start });
fireEvent.drop(cell, {
dataTransfer: { types: ['application/x-calendar-event'], getData: () => json },
});
}
describe('CalendarMonthView drop', () => {
const updateEvent = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
updateEvent.mockClear();
useAuthStore.setState({ client: {} as never });
});
afterEach(() => {
cleanup();
useCalendarStore.setState({ events: [] });
});
it('moves an all-day event with a full start, not a bare date', () => {
const event = makeEvent('a', { start: '2026-10-02T00:00:00', duration: 'P1D', showWithoutTime: true });
useCalendarStore.setState({ events: [event], updateEvent });
renderView([event]);
dropOn(3, event); // Thursday 1 Oct
expect(updateEvent).toHaveBeenCalledWith(expect.anything(), 'a', { start: '2026-10-01T00:00:00' }, undefined);
});
it('leaves an all-day event dropped on its own day alone', () => {
const event = makeEvent('a', { start: '2026-10-02T00:00:00', duration: 'P1D', showWithoutTime: true });
useCalendarStore.setState({ events: [event], updateEvent });
renderView([event]);
dropOn(4, event); // Friday 2 Oct
expect(updateEvent).not.toHaveBeenCalled();
});
it('keeps the time of day of a timed event', () => {
const event = makeEvent('t', { start: '2026-10-02T17:30:00', duration: 'PT1H', timeZone: 'Europe/Berlin' });
useCalendarStore.setState({ events: [event], updateEvent });
renderView([event]);
dropOn(3, event);
expect(updateEvent).toHaveBeenCalledWith(expect.anything(), 't', { start: '2026-10-01T17:30:00' }, undefined);
});
});
@@ -0,0 +1,155 @@
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { CalendarMonthView } from '../calendar-month-view';
import type { CalendarEvent, Calendar, CalendarTask } from '@/lib/jmap/types';
// #1107: with "Show tasks on calendar" on, the month view shows each task
// on its due day, below that day's events, and lets it be completed or
// opened in the task editor.
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
function makeTask(id: string, due: string, overrides: Partial<CalendarTask> = {}): CalendarTask {
return {
id,
'@type': 'Task',
uid: id,
title: 'Task ' + id,
description: '',
due,
start: null,
duration: null,
timeZone: null,
showWithoutTime: true,
progress: 'needs-action',
priority: 0,
privacy: 'public',
keywords: null,
categories: null,
color: null,
created: null,
updated: '2026-09-01T00:00:00Z',
recurrenceRules: null,
alerts: null,
relatedTo: null,
calendarIds: { 'cal-1': true },
...overrides,
};
}
function makeAllDayEvent(id: string, start: string): CalendarEvent {
return {
id,
'@type': 'Event',
uid: id,
title: 'Event ' + id,
start,
duration: 'P1D',
showWithoutTime: true,
calendarIds: { 'cal-1': true },
} as unknown as CalendarEvent;
}
type Props = React.ComponentProps<typeof CalendarMonthView>;
function renderView(overrides: Partial<Props> = {}) {
const props: Props = {
focus: { date: new Date(2026, 8, 9), nonce: 0 },
windowKey: 'month:2026-09-09',
// Monday 31 Aug to Sunday 4 Oct: the September grid.
rangeStart: new Date(2026, 7, 31),
rangeEnd: new Date(2026, 9, 4),
selectedDate: new Date(2026, 8, 9),
events: [],
calendars,
onSelectDate: vi.fn(),
onSelectEvent: vi.fn(),
...overrides,
};
return { props, ...render(<CalendarMonthView {...props} />) };
}
function chipSlot(taskId: string): HTMLElement {
const chip = document.querySelector<HTMLElement>(`[data-calendar-task="${taskId}"]`);
if (!chip?.parentElement) throw new Error(`no chip for ${taskId}`);
return chip.parentElement;
}
describe('CalendarMonthView tasks', () => {
afterEach(() => {
cleanup();
});
it('shows a task on its due day, below that day\'s events', () => {
renderView({
events: [makeAllDayEvent('e1', '2026-09-10')],
tasks: [
makeTask('thu', '2026-09-10'),
makeTask('fri', '2026-09-11T09:00:00', { showWithoutTime: false }),
],
});
expect(screen.getByText('Task thu')).toBeInTheDocument();
expect(screen.getByText('Task fri')).toBeInTheDocument();
// Thursday is the fourth column of the week starting Monday 7 Sep and
// already has an event in row 0; Friday has no events.
// (jsdom rounds the calc() percentage.)
const thu = chipSlot('thu');
expect(thu.style.left).toMatch(/^calc\(42\.857\d*% \+ 1px\)$/);
expect(thu.style.top).toBe('22px');
const fri = chipSlot('fri');
expect(fri.style.left).toMatch(/^calc\(57\.142\d*% \+ 1px\)$/);
expect(fri.style.top).toBe('0px');
});
it('grows the week row to fit a crowded day', () => {
renderView({
events: [makeAllDayEvent('e1', '2026-09-10')],
tasks: ['a', 'b', 'c'].map((id) => makeTask(id, '2026-09-10')),
});
const row = document.querySelector<HTMLElement>('[data-week="2026-09-07"]');
// overlay offset 30 + 4, one event row and three task rows of 22px, 8 below.
expect(row?.style.minHeight).toBe(`${30 + 4 + 4 * 22 + 8}px`);
expect(chipSlot('c').style.top).toBe(`${3 * 22}px`);
});
it('toggles completion from the circle and opens the editor from the title', () => {
const onToggleTaskComplete = vi.fn();
const onSelectTask = vi.fn();
const task = makeTask('t1', '2026-09-10');
const { props } = renderView({ tasks: [task], onToggleTaskComplete, onSelectTask });
fireEvent.click(screen.getByRole('button', { name: 'tasks.mark_complete' }));
expect(onToggleTaskComplete).toHaveBeenCalledWith(task);
expect(onSelectTask).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Task t1' }));
expect(onSelectTask).toHaveBeenCalledWith(task);
expect(onToggleTaskComplete).toHaveBeenCalledTimes(1);
expect(props.onSelectDate).not.toHaveBeenCalled();
});
it('marks completed tasks and offers to reopen them', () => {
renderView({ tasks: [makeTask('done', '2026-09-10', { progress: 'completed' })] });
expect(screen.getByRole('button', { name: 'tasks.mark_incomplete' })).toBeInTheDocument();
expect(screen.getByText('Task done')).toHaveClass('line-through');
});
it('shows no task chips when no tasks are passed', () => {
renderView({ events: [makeAllDayEvent('e1', '2026-09-10')] });
expect(document.querySelector('[data-calendar-task]')).toBeNull();
});
it('shows tasks as rings next to the event dots on mobile', () => {
renderView({
isMobile: true,
events: [makeAllDayEvent('e1', '2026-09-10')],
tasks: [makeTask('t1', '2026-09-10')],
});
const ring = document.querySelector<HTMLElement>('[data-calendar-task="t1"]');
expect(ring?.tagName).toBe('SPAN');
expect(ring).toHaveClass('border');
expect(screen.queryByText('Task t1')).toBeNull();
});
});
@@ -0,0 +1,188 @@
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { CalendarWeekView } from '../calendar-week-view';
import type { CalendarEvent, Calendar, CalendarTask } from '@/lib/jmap/types';
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
function makeTask(id: string, due: string, overrides: Partial<CalendarTask> = {}): CalendarTask {
return {
id,
'@type': 'Task',
uid: id,
title: 'Task ' + id,
description: '',
due,
start: null,
duration: null,
timeZone: null,
showWithoutTime: true,
progress: 'needs-action',
priority: 0,
privacy: 'public',
keywords: null,
categories: null,
color: null,
created: null,
updated: '2026-09-01T00:00:00Z',
recurrenceRules: null,
alerts: null,
relatedTo: null,
calendarIds: { 'cal-1': true },
...overrides,
};
}
function makeAllDayEvent(id: string, start: string): CalendarEvent {
return {
id,
'@type': 'Event',
uid: id,
title: 'Event ' + id,
start,
duration: 'P1D',
showWithoutTime: true,
calendarIds: { 'cal-1': true },
} as unknown as CalendarEvent;
}
type Props = React.ComponentProps<typeof CalendarWeekView>;
function renderView(overrides: Partial<Props> = {}) {
const props: Props = {
focus: { date: new Date(2026, 8, 9), nonce: 0 },
windowKey: 'week:2026-09-07',
// Monday 7 Sep to Sunday 13 Sep 2026
rangeStart: new Date(2026, 8, 7),
rangeEnd: new Date(2026, 8, 13),
selectedDate: new Date(2026, 8, 9),
events: [],
calendars,
onSelectDate: vi.fn(),
onSelectEvent: vi.fn(),
onCreateAtTime: vi.fn(),
...overrides,
};
return { props, ...render(<CalendarWeekView {...props} />) };
}
function chipSlot(taskId: string): HTMLElement {
const chip = document.querySelector<HTMLElement>(`[data-calendar-task="${taskId}"]`);
if (!chip?.parentElement) throw new Error(`no chip for ${taskId}`);
return chip.parentElement;
}
describe('CalendarWeekView all-day and tasks height cap', () => {
afterEach(() => {
cleanup();
});
it('stacks tasks directly below events of their own day instead of global allDayRowCount', () => {
// Monday 2026-09-07 has 2 all-day events
// Tuesday 2026-09-08 has NO events, but 1 task
renderView({
events: [
makeAllDayEvent('e1', '2026-09-07'),
makeAllDayEvent('e2', '2026-09-07'),
],
tasks: [
makeTask('t-tue', '2026-09-08'),
],
});
// Tuesday's task must start at row 0 (top: 2px), NOT pushed down to row 2 (top: 50px)
const tueSlot = chipSlot('t-tue');
expect(tueSlot.style.top).toBe('2px');
});
it('caps all-day height to default max rows (3 rows = 76px) and provides an expand toggle when crowded', () => {
// Monday 2026-09-07 has 5 tasks (rows 0..4)
renderView({
tasks: [
makeTask('t1', '2026-09-07'),
makeTask('t2', '2026-09-07'),
makeTask('t3', '2026-09-07'),
makeTask('t4', '2026-09-07'),
makeTask('t5', '2026-09-07'),
],
});
const toggle = screen.getByRole('button', { name: /all-day-expand/i });
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute('aria-expanded', 'false');
// Default height is capped to 3 rows (3 * 24 + 4 = 76px)
const allDayGrid = document.querySelector<HTMLElement>('[data-testid="all-day-grid"]');
expect(allDayGrid).toBeInTheDocument();
expect(allDayGrid?.style.height).toBe('76px');
// Click toggle to expand
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(allDayGrid?.style.height).toBe('124px'); // 5 * 24 + 4
});
it('sizes the all-day area from the visible week, not from other loaded weeks (#1293)', () => {
// Three weeks loaded (free scrolling), the focused first week is empty;
// a crowded day two weeks later must not add height or a toggle here.
renderView({
rangeStart: new Date(2026, 8, 7),
rangeEnd: new Date(2026, 8, 27),
windowKey: 'week:2026-09-07:3w',
events: [
makeAllDayEvent('e1', '2026-09-23'),
makeAllDayEvent('e2', '2026-09-23'),
makeAllDayEvent('e3', '2026-09-23'),
makeAllDayEvent('e4', '2026-09-23'),
],
});
expect(screen.queryByRole('button', { name: /all-day-expand/i })).toBeNull();
expect(document.querySelector('[data-testid="all-day-grid"]')).toBeNull();
});
it('still sizes the all-day area from entries in the visible week (#1293)', () => {
renderView({
rangeStart: new Date(2026, 8, 7),
rangeEnd: new Date(2026, 8, 27),
windowKey: 'week:2026-09-07:3w',
events: [
makeAllDayEvent('e1', '2026-09-08'),
makeAllDayEvent('e2', '2026-09-23'),
makeAllDayEvent('e3', '2026-09-23'),
makeAllDayEvent('e4', '2026-09-23'),
],
});
const allDayGrid = document.querySelector<HTMLElement>('[data-testid="all-day-grid"]');
expect(allDayGrid?.style.height).toBe('28px');
expect(screen.queryByRole('button', { name: /all-day-expand/i })).toBeNull();
});
it('resizes the all-day area when scrolling to a crowded week (#1293)', async () => {
renderView({
rangeStart: new Date(2026, 8, 7),
rangeEnd: new Date(2026, 8, 27),
windowKey: 'week:2026-09-07:3w',
events: [
makeAllDayEvent('e1', '2026-09-23'),
makeAllDayEvent('e2', '2026-09-23'),
makeAllDayEvent('e3', '2026-09-23'),
makeAllDayEvent('e4', '2026-09-23'),
],
});
expect(document.querySelector('[data-testid="all-day-grid"]')).toBeNull();
// Scroll the strip to Monday 21 Sep: 14 columns of the measured width
const strip = document.querySelector<HTMLElement>('[role="grid"]')!;
const columns = document.querySelector<HTMLElement>('[style*="grid-template-columns"]')!;
const colWidth = parseInt(/(\d+)px/.exec(columns.style.gridTemplateColumns)![1], 10);
strip.scrollLeft = 14 * colWidth;
fireEvent.scroll(strip);
await new Promise((r) => requestAnimationFrame(() => r(null)));
const allDayGrid = await screen.findByTestId('all-day-grid');
expect(allDayGrid.style.height).toBe('76px');
expect(screen.getByRole('button', { name: /all-day-expand/i })).toBeInTheDocument();
});
});
@@ -0,0 +1,92 @@
import { render, screen, cleanup } from '@testing-library/react';
import { describe, it, expect, afterEach } from 'vitest';
import { EventCard } from '../event-card';
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
// #1110: an invitation the user declined - the whole series or one occurrence
// of an accepted series - stays on the calendar, struck through and drawn as
// an outline like a cancelled event, instead of looking like one they will
// attend.
function makeOccurrence(id: string, myStatus: CalendarParticipant['participationStatus']): CalendarEvent {
return {
id,
'@type': 'Event',
uid: 'series',
title: 'Standup ' + id,
start: '2026-09-28T09:00:00',
duration: 'PT30M',
showWithoutTime: false,
status: 'confirmed',
calendarIds: { 'cal-1': true },
participants: {
org: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.com', participationStatus: 'accepted' },
me: { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', participationStatus: myStatus },
},
} as unknown as CalendarEvent;
}
const me = ['me@example.com'];
describe('EventCard', () => {
afterEach(() => {
cleanup();
});
it.each(['chip', 'span', 'block'] as const)('strikes through an occurrence the user declined (%s)', (variant) => {
render(<EventCard event={makeOccurrence('a', 'declined')} variant={variant} currentUserEmails={me} />);
const card = screen.getByRole('button');
expect(card.style.boxShadow).toContain('inset 0 0 0 1px');
expect(card.getAttribute('aria-label')).toMatch(/, participants\.declined$/);
expect(screen.getByText('Standup a')).toHaveClass('line-through');
});
it('leaves accepted and unanswered occurrences alone', () => {
render(
<>
<EventCard event={makeOccurrence('a', 'accepted')} variant="block" currentUserEmails={me} />
<EventCard event={makeOccurrence('b', 'needs-action')} variant="block" currentUserEmails={me} />
</>
);
for (const card of screen.getAllByRole('button')) {
expect(card.style.boxShadow).not.toContain('inset');
expect(card.getAttribute('aria-label')).not.toMatch(/declined/);
}
expect(screen.getByText('Standup a')).not.toHaveClass('line-through');
});
it('only looks at the user\'s own participant entry', () => {
const event = makeOccurrence('a', 'accepted');
event.participants!.org.participationStatus = 'declined';
render(<EventCard event={event} variant="block" currentUserEmails={me} />);
expect(screen.getByRole('button').style.boxShadow).not.toContain('inset');
});
it('labels a cancelled event as cancelled even when the user declined it', () => {
const event = { ...makeOccurrence('a', 'declined'), status: 'cancelled' } as CalendarEvent;
render(<EventCard event={event} variant="block" currentUserEmails={me} />);
expect(screen.getByRole('button').getAttribute('aria-label')).toMatch(/, detail\.cancelled$/);
});
});
describe('EventCard fill', () => {
afterEach(() => {
cleanup();
});
const calendar = { id: 'cal-1', name: 'Work', color: '#22c55e' } as unknown as Calendar;
it.each(['chip', 'span', 'block'] as const)('fills the %s with the calendar colour and readable text', (variant) => {
render(<EventCard event={makeOccurrence('a', 'accepted')} calendar={calendar} variant={variant} currentUserEmails={me} />);
const card = screen.getByRole('button');
expect(card.style.backgroundColor).toBe('rgb(34, 197, 94)');
expect(card.style.color).toBe('rgb(17, 24, 39)');
});
it('keeps the selection ring next to the declined outline', () => {
render(<EventCard event={makeOccurrence('a', 'declined')} calendar={calendar} variant="block" isSelected currentUserEmails={me} />);
const shadow = screen.getByRole('button').style.boxShadow;
expect(shadow).toContain('0 0 0 2px var(--color-primary)');
expect(shadow).toContain('inset 0 0 0 1px');
});
});
+16 -12
View File
@@ -4,12 +4,12 @@ import { useMemo, useRef, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
import { format, isTomorrow, startOfDay } from "date-fns";
import { MapPin, Users } from "lucide-react";
import { MapPin, Users } from "@/components/icons";
import { cn } from "@/lib/utils";
import { getEventColor } from "./event-card";
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { displayNow, isDisplayToday } from "@/lib/timezone";
import { getParticipantCount } from "@/lib/calendar-participants";
import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants";
import { useScrollWindow } from "@/hooks/use-scroll-window";
import type { ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -22,6 +22,8 @@ interface CalendarAgendaViewProps extends ScrollWindowViewProps {
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
timeFormat?: "12h" | "24h";
/** The user's calendar addresses, to mark events they declined (#1110). */
currentUserEmails?: string[];
}
interface DayGroup {
@@ -45,6 +47,7 @@ export function CalendarAgendaView({
onHoverLeave,
onContextMenuEvent,
timeFormat = "24h",
currentUserEmails,
}: CalendarAgendaViewProps) {
const t = useTranslations("calendar");
// Grid days / event dates are display dates (local fields = wall-clock in
@@ -207,8 +210,9 @@ export function CalendarAgendaView({
const start = getEventStartDate(ev);
const end = getEventEndDate(ev);
// iTIP CANCEL marks the attendee's copy with status "cancelled"
// instead of deleting it (#572).
const isCancelled = ev.status === "cancelled";
// instead of deleting it (#572); a declined invitation stays
// listed too (#1110).
const isInactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails);
const locationName = ev.locations
? Object.values(ev.locations)[0]?.name
: null;
@@ -220,10 +224,7 @@ export function CalendarAgendaView({
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onHoverLeave?.()}
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
className={cn(
"w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-start",
isCancelled && "opacity-60"
)}
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-start"
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
@@ -239,13 +240,16 @@ export function CalendarAgendaView({
)}
</div>
<div
className="w-1 self-stretch rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
{/* Calendar colour as a dot on the title line; hollow when
declined or cancelled (repos/branding/APP.md). */}
<span
className="w-[9px] h-[9px] mt-[5.5px] rounded-full flex-shrink-0"
style={isInactive ? { boxShadow: `inset 0 0 0 1.5px ${color}` } : { backgroundColor: color }}
aria-hidden="true"
/>
<div className="flex-1 min-w-0">
<div className={cn("text-sm font-medium truncate", isCancelled && "line-through")}>
<div className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")}>
{ev.title || t("events.no_title")}
</div>
{locationName && (
+185 -79
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useRef, useMemo, type TouchEvent as ReactTouchEvent } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { Plus } from "lucide-react";
import { Plus } from "@/components/icons";
import {
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
format, parseISO,
@@ -47,22 +47,31 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useIsFocusedProTab } from "@/hooks/use-pane-context";
import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context";
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { buildRecurrenceOverridePatch } from "@/lib/recurrence-overrides";
import { baseEventStoreId, isServerRecurrenceInstance } from "@/lib/recurrence-instances";
import { filterTasksByCalendars } from "@/lib/calendar-tasks";
import {
baseEventStoreId,
buildFallbackExcludePatch,
buildFallbackOverridePatch,
isBrowserExpandedOccurrence,
isServerRecurrenceInstance,
overrideContextOf,
withNewOverrideDetails,
} from "@/lib/recurrence-instances";
import { getClientByLocalAccountId } from "@/stores/client-registry";
import { getEventStartDate } from "@/lib/calendar-utils";
import { displayNow } from "@/lib/timezone";
import { useTaskStore } from "@/stores/task-store";
import { useContactStore } from "@/stores/contact-store";
import { cn } from "@/lib/utils";
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types";
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights, CalendarTask } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { SchedulingDeniedError } from "@/lib/jmap/scheduling-error";
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
import { getUserParticipantId, collectUserCalendarAddresses } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
@@ -84,7 +93,8 @@ import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
type PendingScopeAction =
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
| { type: "delete"; event: CalendarEvent; sendScheduling?: boolean };
| { type: "delete"; event: CalendarEvent; sendScheduling?: boolean }
| { type: "rsvp"; event: CalendarEvent; participantId: string; status: CalendarParticipant["participationStatus"] };
function isRecurringEvent(event: CalendarEvent): boolean {
return (event.recurrenceRules?.length ?? 0) > 0 || event.recurrenceId != null;
@@ -129,6 +139,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
const taskStore = useTaskStore();
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
const toggleTaskCompleteFn = useTaskStore(state => state.toggleTaskComplete);
const { identities } = useIdentityStore();
const contacts = useContactStore((s) => s.contacts);
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
@@ -567,24 +578,45 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
setDefaultModalEndDate(endDate);
setDefaultModalAllDay(allDay ?? false);
setSelectedDate(d);
setShowTaskModal(false);
setEditTask(null);
setShowEventModal(true);
}, [selectedDate, setSelectedDate]);
const openEditModal = useCallback((event: CalendarEvent) => {
setEditEvent(event);
setDefaultModalDate(undefined);
setShowTaskModal(false);
setEditTask(null);
setShowEventModal(true);
}, []);
const openCreateTaskModal = useCallback(() => {
setEditTask(null);
setShowTaskModal(true);
// The event and task panels share the space beside the grid; now that
// tasks open from the month, week and day views (#1107), opening one
// closes the other.
const closeEventPanelForTask = useCallback(() => {
setShowEventModal(false);
setEditEvent(null);
setPendingPreview(null);
setDefaultCalendarIdForCreate(undefined);
setDefaultModalAllDay(false);
}, []);
const openEditTaskModal = useCallback((task: import("@/lib/jmap/types").CalendarTask) => {
const openCreateTaskModal = useCallback(() => {
closeEventPanelForTask();
setEditTask(null);
setShowTaskModal(true);
}, [closeEventPanelForTask]);
const openEditTaskModal = useCallback((task: CalendarTask) => {
closeEventPanelForTask();
setEditTask(task);
setShowTaskModal(true);
}, []);
}, [closeEventPanelForTask]);
const handleToggleTaskComplete = useCallback((task: CalendarTask) => {
if (client) toggleTaskCompleteFn(client, task);
}, [client, toggleTaskCompleteFn]);
const handleSaveTask = useCallback(async (data: Partial<import("@/lib/jmap/types").CalendarTask>) => {
if (!client) return;
@@ -737,6 +769,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// that renders while Pro takes over a route stays silent.
const proInterfaceActive = useProInterfaceActive();
const isFocusedProTab = useIsFocusedProTab();
const isPaneScoped = useIsPaneScoped();
const eventLinkId = showEventModal && editEvent ? editEvent.id : null;
const calendarLinkPath = appPath(buildCalendarPath({
view: normalizedViewMode,
@@ -842,45 +875,59 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
jumpTo(eventDate);
}, [jumpTo]);
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, requestedScheduling?: boolean) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
try {
if (editEvent) {
if (isRecurringEvent(editEvent)) {
setPendingScopeAction({
type: "edit",
event: editEvent,
updates: data,
sendScheduling: sendSchedulingMessages,
});
setShowEventModal(false);
setEditEvent(null);
return;
}
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
if (data.start) {
focusCalendarOnEvent({ start: data.start });
}
toast.success(t("notifications.event_updated"));
} else {
const created = await createEvent(client, data, sendSchedulingMessages);
if (!created) {
toast.error(t("notifications.event_error"));
return;
}
focusCalendarOnEvent(created);
if (sendSchedulingMessages) {
toast.success(t("notifications.invitation_sent"));
const save = async (sendSchedulingMessages: boolean | undefined): Promise<void> => {
try {
if (editEvent) {
if (isRecurringEvent(editEvent)) {
setPendingScopeAction({
type: "edit",
event: editEvent,
updates: data,
sendScheduling: sendSchedulingMessages,
});
setShowEventModal(false);
setEditEvent(null);
return;
}
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
if (data.start) {
focusCalendarOnEvent({ start: data.start });
}
toast.success(t("notifications.event_updated"));
} else {
toast.success(t("notifications.event_created"));
const created = await createEvent(client, data, sendSchedulingMessages);
if (!created) {
toast.error(t("notifications.event_error"));
return;
}
focusCalendarOnEvent(created);
if (sendSchedulingMessages) {
toast.success(t("notifications.invitation_sent"));
} else {
toast.success(t("notifications.event_created"));
}
}
setShowEventModal(false);
setEditEvent(null);
} catch (error) {
// The server refuses to send the invitations (Stalwart 0.16.21+ fails
// the whole save then). Offer to keep the event without them.
if (error instanceof SchedulingDeniedError && sendSchedulingMessages) {
const ok = await confirmAction({
title: t("notifications.invitations_denied_title"),
message: t("notifications.invitations_denied", { reason: error.reason }),
confirmText: t("notifications.save_without_invitations"),
});
if (ok) await save(false);
return;
}
toast.error(t("notifications.event_error"));
}
setShowEventModal(false);
setEditEvent(null);
} catch {
toast.error(t("notifications.event_error"));
}
}, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, t]);
};
await save(requestedScheduling);
}, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, confirmAction, t]);
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
@@ -944,8 +991,29 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
return { master, originalRules };
}, [client, findMasterEvent, updateEvent]);
const submitRsvp = useCallback(async (
eventId: string,
participantId: string,
status: CalendarParticipant['participationStatus'],
scope: 'occurrence' | 'series' = 'series',
) => {
if (!client) return;
try {
await rsvpEvent(client, eventId, participantId, status, undefined, scope);
toast.success(t("notifications.rsvp_updated"));
} catch {
toast.error(t("notifications.rsvp_error"));
}
}, [client, rsvpEvent, t]);
const handleScopeSelect = useCallback(async (scope: RecurrenceEditScope) => {
if (!client || !pendingScopeAction) { toast.error(t("notifications.event_error")); return; }
if (pendingScopeAction.type === "rsvp") {
const { event, participantId, status } = pendingScopeAction;
setPendingScopeAction(null);
await submitRsvp(event.id, participantId, status, scope === "this" ? "occurrence" : "series");
return;
}
const { type, event, sendScheduling } = pendingScopeAction;
const updates = type === "edit" ? pendingScopeAction.updates : undefined;
setPendingScopeAction(null);
@@ -954,18 +1022,20 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
if (type === "edit" && updates) {
switch (scope) {
case "this": {
if (isServerRecurrenceInstance(event)) {
// A server-expanded occurrence is written through its own
// (synthetic) id; the store handles the older-server fallback.
if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) {
// The store writes one occurrence through its own (synthetic)
// id, or as a recurrence override on the base event it was
// expanded from.
await updateEvent(client, event.id, updates, sendScheduling);
break;
}
// Client-side expanded occurrence: patch the master event's
// recurrenceOverrides instead.
// An override outside an expanded series: override it on the master.
const master = await findMasterEvent(event);
if (master && event.recurrenceId) {
const patchUpdates = buildRecurrenceOverridePatch(updates, event.recurrenceId);
await updateEvent(client, master.id, patchUpdates, sendScheduling);
const occurrence = master && overrideContextOf(event, master);
const overridePatch = occurrence
&& buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates));
if (master && overridePatch) {
await updateEvent(client, master.id, overridePatch, sendScheduling);
} else {
await updateEvent(client, event.id, updates, sendScheduling);
}
@@ -1029,18 +1099,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
} else {
switch (scope) {
case "this": {
if (isServerRecurrenceInstance(event)) {
if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) {
// The store destroys a server occurrence, or excludes a
// browser-expanded one on its base event.
await deleteEvent(client, event.id, sendScheduling);
break;
}
// Client-side expanded occurrence: exclude the instance via
// recurrenceOverrides on the master event.
// An override outside an expanded series: exclude it on the master.
const delMaster = await findMasterEvent(event);
if (delMaster && event.recurrenceId) {
await updateEvent(
client, delMaster.id,
{ [`recurrenceOverrides/${event.recurrenceId}`]: { excluded: true } } as Partial<CalendarEvent>,
);
const excludePatch = delMaster && buildFallbackExcludePatch(overrideContextOf(event, delMaster));
if (delMaster && excludePatch) {
await updateEvent(client, delMaster.id, excludePatch, sendScheduling);
} else {
await deleteEvent(client, event.id, sendScheduling);
}
@@ -1078,17 +1147,19 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
} catch {
toast.error(t("notifications.event_error"));
}
}, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, truncateRecurrenceAtEvent, refetchCurrentRange, t]);
}, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, truncateRecurrenceAtEvent, refetchCurrentRange, submitRsvp, t]);
const handleRsvp = useCallback(async (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => {
if (!client) return;
try {
await rsvpEvent(client, eventId, participantId, status);
toast.success(t("notifications.rsvp_updated"));
} catch {
toast.error(t("notifications.rsvp_error"));
// An answer on one occurrence of a series asks whether it covers just
// that occurrence or the whole series (#1086).
const occurrence = events.find(e => e.id === eventId && e.recurrenceId != null);
if (occurrence) {
setPendingScopeAction({ type: "rsvp", event: occurrence, participantId, status });
return;
}
}, [client, rsvpEvent, t]);
await submitRsvp(eventId, participantId, status);
}, [client, events, submitRsvp]);
const handleDeleteFromDetail = useCallback(() => {
if (!detailEvent) return;
@@ -1306,6 +1377,13 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
return filtered;
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
// Tasks on the month, week and day grids follow the calendar filter like
// the events do (#1107).
const calendarTasks = useMemo(() => {
if (!enableCalendarTasks || !showTasksOnCalendar) return undefined;
return filterTasksByCalendars(taskStore.tasks, selectedCalendarIds);
}, [enableCalendarTasks, showTasksOnCalendar, taskStore.tasks, selectedCalendarIds]);
useEffect(() => {
const hiddenEvents = events.filter((event) => {
if (!event.start || !event.calendarIds) {
@@ -1413,6 +1491,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile}
pendingPreview={pendingPreview}
tasks={calendarTasks}
onToggleTaskComplete={handleToggleTaskComplete}
onSelectTask={openEditTaskModal}
currentUserEmails={currentUserEmails}
/>
);
case "week":
@@ -1433,8 +1515,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
timeFormat={timeFormat}
isMobile={isMobile}
pendingPreview={pendingPreview}
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
tasks={calendarTasks}
onToggleTaskComplete={handleToggleTaskComplete}
onSelectTask={openEditTaskModal}
currentUserEmails={currentUserEmails}
/>
);
case "day":
@@ -1453,8 +1537,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
timeFormat={timeFormat}
isMobile={isMobile}
pendingPreview={pendingPreview}
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
tasks={calendarTasks}
onToggleTaskComplete={handleToggleTaskComplete}
onSelectTask={openEditTaskModal}
currentUserEmails={currentUserEmails}
/>
);
case "agenda":
@@ -1468,6 +1554,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
/>
);
case "tasks":
@@ -1487,7 +1574,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
filter={taskStore.filter}
showCompleted={taskStore.showCompleted}
onSelectTask={openEditTaskModal}
onToggleComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
onToggleComplete={handleToggleTaskComplete}
selectedTaskId={taskStore.selectedTaskId}
onQuickCreate={(title) => {
if (client) {
@@ -1675,8 +1762,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/>
{/* isolate keeps the views' sticky headers (z-50) below the toolbar's
import dropdown, which overlaps this area (#1049). */}
<div
className="flex flex-1 overflow-hidden relative"
className="flex flex-1 overflow-hidden relative isolate"
data-tour="calendar-view"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
@@ -1802,10 +1891,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
d.setHours(0, 0, 0, 0);
openCreateModal(d, undefined, true);
}}
onNewTask={enableCalendarTasks ? () => {
setEditTask(null);
setShowTaskModal(true);
} : undefined}
onNewTask={enableCalendarTasks ? openCreateTaskModal : undefined}
onGoToToday={goToToday}
/>
);
@@ -1851,6 +1937,26 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
/>
)}
{/* Mobile task editor: full screen, like the event editor. */}
{showTaskModal && isMobile && (
<div
role="dialog"
aria-modal="true"
aria-label={editTask ? t("tasks.edit") : t("tasks.create")}
className={isPaneScoped ? "absolute inset-0 z-40 flex flex-col bg-background" : "fixed inset-0 z-50 flex flex-col bg-background"}
>
<TaskModal
key={editTask?.id ?? 'new-task'}
task={editTask}
calendars={displayCalendars}
onSave={handleSaveTask}
onDelete={handleDeleteTask}
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
isMobile={true}
/>
</div>
)}
{showImportModal && client && (
<ICalImportModal
calendars={displayCalendars}
+16 -17
View File
@@ -3,13 +3,14 @@
import { useMemo, useEffect, useLayoutEffect, useRef, useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
import { format, isSameDay, parseISO, eachDayOfInterval, differenceInCalendarDays } from "date-fns";
import { format, isSameDay, eachDayOfInterval, differenceInCalendarDays } from "date-fns";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { Check } from "@/components/icons";
import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
import { displayNow, isDisplayToday } from "@/lib/timezone";
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
@@ -31,6 +32,9 @@ interface CalendarDayViewProps extends ScrollWindowViewProps {
pendingPreview?: PendingEventPreview | null;
tasks?: CalendarTask[];
onToggleTaskComplete?: (task: CalendarTask) => void;
onSelectTask?: (task: CalendarTask) => void;
/** The user's calendar addresses, to mark events they declined (#1110). */
currentUserEmails?: string[];
}
const HOUR_HEIGHT = 64;
@@ -60,6 +64,8 @@ export function CalendarDayView({
pendingPreview,
tasks,
onToggleTaskComplete,
onSelectTask,
currentUserEmails,
}: CalendarDayViewProps) {
const t = useTranslations("calendar");
// Grid days / event dates are display dates (local fields = wall-clock in
@@ -142,20 +148,7 @@ export function CalendarDayView({
return map;
}, [events]);
const tasksByDay = useMemo(() => {
const map = new Map<string, CalendarTask[]>();
if (!tasks?.length) return map;
for (const task of tasks) {
if (!task.due) continue;
try {
const key = format(parseISO(task.due), "yyyy-MM-dd");
const existing = map.get(key) || [];
existing.push(task);
map.set(key, existing);
} catch { /* skip */ }
}
return map;
}, [tasks]);
const tasksByDay = useMemo(() => groupTasksByDueDay(tasks), [tasks]);
// Column layouts are the costly part of a render; with months of columns
// they must not be redone on every scroll-driven re-render.
@@ -338,6 +331,7 @@ export function CalendarDayView({
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
/>
);
})}
@@ -355,10 +349,13 @@ export function CalendarDayView({
return (
<div
key={task.id}
data-calendar-task={task.id}
onClick={() => onSelectTask?.(task)}
className="flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs cursor-pointer hover:bg-muted/50 transition-colors"
style={{ borderLeft: `3px solid ${color}` }}
style={{ borderInlineStart: `3px solid ${color}` }}
>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onToggleTaskComplete?.(task); }}
className={cn(
"flex-shrink-0 w-3.5 h-3.5 rounded-full border flex items-center justify-center",
@@ -366,6 +363,7 @@ export function CalendarDayView({
? "bg-success border-success text-success-foreground"
: "border-muted-foreground/40 hover:border-primary"
)}
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
>
{isCompleted && <Check className="h-2.5 w-2.5" />}
</button>
@@ -462,6 +460,7 @@ export function CalendarDayView({
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
draggable
/>
<div
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { Calendar as CalendarIcon, CheckSquare, Layers } from "lucide-react";
import { Calendar as CalendarIcon, CheckSquare, Layers } from "@/components/icons";
import { cn } from "@/lib/utils";
import type { CalendarComponentType } from "@/lib/jmap/types";
+80 -9
View File
@@ -5,8 +5,11 @@ import { useTranslations } from "next-intl";
import { format, parseISO, eachDayOfInterval, addDays } from "date-fns";
import { cn } from "@/lib/utils";
import { EventCard } from "./event-card";
import { CalendarTaskChip } from "./task-chip";
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
import { isDeclinedByUser } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -30,6 +33,11 @@ interface CalendarMonthViewProps extends ScrollWindowViewProps {
firstDayOfWeek?: number;
isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
tasks?: CalendarTask[];
onToggleTaskComplete?: (task: CalendarTask) => void;
onSelectTask?: (task: CalendarTask) => void;
/** The user's calendar addresses, to mark events they declined (#1110). */
currentUserEmails?: string[];
}
/** Fraction of the viewport height at which the "current month" is sampled. */
@@ -56,6 +64,10 @@ export function CalendarMonthView({
onCreateAtTime,
isMobile,
pendingPreview,
tasks,
onToggleTaskComplete,
onSelectTask,
currentUserEmails,
}: CalendarMonthViewProps) {
const t = useTranslations("calendar");
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
@@ -115,13 +127,28 @@ export function CalendarMonthView({
return map;
}, [events]);
const tasksByDate = useMemo(() => groupTasksByDueDay(tasks), [tasks]);
const weekSegments = useMemo(() => {
return weeks.map((week) => {
const segments = buildWeekSegments(events, week);
const rowCount = segments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
return { week, segments, rowCount };
// Tasks stack under the lowest event of their own day rather than of
// the whole week, so a busy Monday does not push Friday's tasks down.
const dayEventRows = week.map((_, dayIndex) => segments.reduce(
(rows, segment) => dayIndex >= segment.startIndex && dayIndex < segment.startIndex + segment.span
? Math.max(rows, segment.row + 1)
: rows,
0,
));
const dayTasks = week.map((day) => tasksByDate.get(format(day, "yyyy-MM-dd")) ?? []);
const contentRows = dayTasks.reduce(
(rows, dayTaskList, dayIndex) => Math.max(rows, dayEventRows[dayIndex] + dayTaskList.length),
rowCount,
);
return { week, segments, rowCount, dayEventRows, dayTasks, contentRows };
});
}, [events, weeks]);
}, [events, weeks, tasksByDate]);
const scrollRef = useRef<HTMLDivElement>(null);
const topSentinelRef = useRef<HTMLDivElement>(null);
@@ -228,7 +255,9 @@ export function CalendarMonthView({
const isAllDay = event?.showWithoutTime;
const newStart = new Date(day);
newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0);
const newStartISO = isAllDay ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
// `start` is a LocalDateTime for all-day events too: Stalwart drops a
// date-only value and the event is left without a start (#1119).
const newStartISO = format(newStart, isAllDay ? "yyyy-MM-dd'T'00:00:00" : "yyyy-MM-dd'T'HH:mm:ss");
if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client;
if (!client) return;
@@ -258,10 +287,10 @@ export function CalendarMonthView({
onScroll={handleScroll}
>
<div ref={topSentinelRef} data-testid="month-top-sentinel" className="h-px flex-shrink-0" />
{weekSegments.map(({ week, segments, rowCount }) => (
{weekSegments.map(({ week, segments, rowCount, dayEventRows, dayTasks, contentRows }) => (
<div key={dayKey(week[0])} data-week={dayKey(week[0])} className="relative flex-shrink-0 border-b border-border" role="row" style={{
minHeight: showChips
? Math.max(rowMinHeight, overlayTop + 4 + rowCount * rowHeight + 8)
? Math.max(rowMinHeight, overlayTop + 4 + contentRows * rowHeight + 8)
: rowMinHeight,
}}>
<div className="grid grid-cols-7 h-full">
@@ -271,6 +300,7 @@ export function CalendarMonthView({
const today = checkIsToday(day);
const key = format(day, "yyyy-MM-dd");
const dayEvents = eventsByDate.get(key) || [];
const dayTaskList = dayTasks[dayIndex];
const fullDateLabel = formatFullDate(day);
const previous = dayIndex > 0 ? week[dayIndex - 1] : addDays(day, -1);
const firstOfMonth = !checkIsSameMonth(day, previous);
@@ -320,15 +350,28 @@ export function CalendarMonthView({
const calId = getPrimaryCalendarId(ev);
const cal = calId ? calendarMap.get(calId) : undefined;
const evColor = ev.color || cal?.color || "#3b82f6";
const inactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails);
return (
<span
key={ev.id}
className="w-1.5 h-1.5 rounded-full"
className={cn("w-1.5 h-1.5 rounded-full", inactive && "opacity-40")}
style={{ backgroundColor: evColor }}
/>
);
})}
{dayEvents.length > 3 && (
{/* Tasks are rings where events are filled dots. */}
{dayTaskList.slice(0, Math.max(0, 3 - dayEvents.length)).map((task) => {
const calId = Object.keys(task.calendarIds).find((id) => calendarMap.has(id));
return (
<span
key={`task-${task.id}`}
data-calendar-task={task.id}
className={cn("w-1.5 h-1.5 rounded-full border", task.progress === "completed" && "opacity-40")}
style={{ borderColor: (calId && calendarMap.get(calId)?.color) || "#3b82f6" }}
/>
);
})}
{dayEvents.length + dayTaskList.length > 3 && (
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
)}
{pendingPreview && checkIsSameDay(pendingPreview.start, day) && (
@@ -347,7 +390,7 @@ export function CalendarMonthView({
{showChips && pendingPreview && (() => {
const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start));
if (previewDayIdx === -1) return null;
const previewRow = rowCount;
const previewRow = Math.max(rowCount, dayEventRows[previewDayIdx] + dayTasks[previewDayIdx].length);
const cal = calendarMap.get(pendingPreview.calendarId);
const color = cal?.color || "#3b82f6";
return (
@@ -400,6 +443,7 @@ export function CalendarMonthView({
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
draggable
className={isMobile ? "text-[10px] px-1" : undefined}
/>
@@ -408,6 +452,33 @@ export function CalendarMonthView({
})}
</div>
)}
{showChips && dayTasks.some((dayTaskList) => dayTaskList.length > 0) && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
{dayTasks.map((dayTaskList, dayIndex) => dayTaskList.map((task, taskIndex) => {
const calId = Object.keys(task.calendarIds).find((id) => calendarMap.has(id));
return (
<div
key={`task-${task.id}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(dayIndex / 7) * 100}% + 1px)`,
width: `calc(${(1 / 7) * 100}% - 2px)`,
top: (dayEventRows[dayIndex] + taskIndex) * rowHeight,
height: chipHeight,
}}
>
<CalendarTaskChip
task={task}
calendar={calId ? calendarMap.get(calId) : undefined}
onToggleComplete={onToggleTaskComplete}
onSelect={onSelectTask}
/>
</div>
);
}))}
</div>
)}
</div>
))}
<div ref={bottomSentinelRef} data-testid="month-bottom-sentinel" className="h-px flex-shrink-0" />
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "@/components/icons";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
+1 -1
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "@/components/icons";
import { startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
+115 -79
View File
@@ -4,12 +4,13 @@ import { useMemo, useEffect, useLayoutEffect, useRef, useState, useCallback } fr
import { useTranslations } from "next-intl";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
import {
startOfWeek, format, isSameDay, parseISO, eachDayOfInterval, differenceInCalendarDays,
startOfWeek, format, isSameDay, eachDayOfInterval, differenceInCalendarDays,
} from "date-fns";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { CalendarTaskChip } from "./task-chip";
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils";
import { displayNow, isDisplayToday } from "@/lib/timezone";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
@@ -35,6 +36,9 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps {
pendingPreview?: PendingEventPreview | null;
tasks?: CalendarTask[];
onToggleTaskComplete?: (task: CalendarTask) => void;
onSelectTask?: (task: CalendarTask) => void;
/** The user's calendar addresses, to mark events they declined (#1110). */
currentUserEmails?: string[];
}
const HOUR_HEIGHT = 60;
@@ -67,6 +71,8 @@ export function CalendarWeekView({
pendingPreview,
tasks,
onToggleTaskComplete,
onSelectTask,
currentUserEmails,
}: CalendarWeekViewProps) {
const t = useTranslations("calendar");
// Grid days / event dates are display dates (local fields = wall-clock in
@@ -94,15 +100,22 @@ export function CalendarWeekView({
return Math.max(MIN_COL_WIDTH, Math.floor((root.clientWidth - gutterWidth) / 7));
}, [isMobile, gutterWidth]);
const [colWidth, setColWidth] = useState(MOBILE_COL_WIDTH);
const [viewportWidth, setViewportWidth] = useState(0);
// First day in view, reported by the scroll handler below (#1293)
const [visibleDayKey, setVisibleDayKey] = useState<string | null>(null);
useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
setColWidth(measureColWidth(root));
const measure = () => {
setColWidth(measureColWidth(root));
setViewportWidth(Math.max(0, root.clientWidth - gutterWidth));
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => setColWidth(measureColWidth(root)));
const observer = new ResizeObserver(measure);
observer.observe(root);
return () => observer.disconnect();
}, [measureColWidth]);
}, [measureColWidth, gutterWidth]);
// Every scroll offset is computed with the column width that is rendered.
// When that width changes (first measurement, resize) keep the same day at
@@ -171,40 +184,55 @@ export function CalendarWeekView({
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
}, [events, days]);
const allDayRowCount = useMemo(() => {
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
}, [allDaySegments]);
const tasksByDay = useMemo(() => groupTasksByDueDay(tasks), [tasks]);
// Tasks grouped by day
const tasksByDay = useMemo(() => {
if (!tasks?.length) return new Map<string, CalendarTask[]>();
const map = new Map<string, CalendarTask[]>();
for (const task of tasks) {
if (!task.due) continue;
try {
const key = format(parseISO(task.due), "yyyy-MM-dd");
const existing = map.get(key) || [];
existing.push(task);
map.set(key, existing);
} catch { /* skip */ }
}
return map;
}, [tasks]);
// Tasks stack under the events of their own day rather than globally (#1107, #1270)
const dayEventRows = useMemo(() => {
return days.map((_, dayIndex) =>
allDaySegments.reduce(
(rows, segment) =>
dayIndex >= segment.startIndex && dayIndex < segment.startIndex + segment.span
? Math.max(rows, segment.row + 1)
: rows,
0,
)
);
}, [days, allDaySegments]);
// Max tasks on any single loaded day
const taskRowCount = useMemo(() => {
// The strip holds weeks to months of days (#759), but the all-day area is
// sized from the columns in view: a crowded day in another week must not
// add empty rows or an expand toggle here (#1293). The first visible day is
// tracked by date, so columns prepended while loading do not shift it.
const visibleStartIndex = useMemo(() => {
const tracked = visibleDayKey ? days.findIndex((d) => dayKey(d) === visibleDayKey) : -1;
if (tracked >= 0) return tracked;
const target = isMobile ? focus.date : startOfWeek(focus.date, { weekStartsOn: weekStart });
return Math.max(0, Math.min(days.length - 1, differenceInCalendarDays(target, rangeStart)));
}, [visibleDayKey, days, isMobile, focus.date, weekStart, rangeStart]);
const visibleColumns = isMobile ? Math.max(1, Math.ceil(viewportWidth / colWidth)) : 7;
// Max combined rows (events + tasks) across the visible days
const maxContentRows = useMemo(() => {
let max = 0;
for (const day of days) {
const key = format(day, "yyyy-MM-dd");
const count = tasksByDay.get(key)?.length ?? 0;
if (count > max) max = count;
const end = Math.min(days.length, visibleStartIndex + visibleColumns);
for (let i = visibleStartIndex; i < end; i++) {
const key = format(days[i], "yyyy-MM-dd");
const taskCount = tasksByDay.get(key)?.length ?? 0;
const total = dayEventRows[i] + taskCount;
if (total > max) max = total;
}
return max;
}, [tasksByDay, days]);
}, [days, dayEventRows, tasksByDay, visibleStartIndex, visibleColumns]);
const hasAllDay = useMemo(() => {
return allDaySegments.length > 0 || taskRowCount > 0;
}, [allDaySegments, taskRowCount]);
return maxContentRows > 0;
}, [maxContentRows]);
const DEFAULT_ALL_DAY_MAX_ROWS = 3;
const [isAllDayExpanded, setIsAllDayExpanded] = useState(false);
const isExpandable = maxContentRows > DEFAULT_ALL_DAY_MAX_ROWS;
const visibleRows = isAllDayExpanded ? maxContentRows : Math.min(DEFAULT_ALL_DAY_MAX_ROWS, maxContentRows);
const allDayHeight = Math.max(28, visibleRows * 24 + 4);
useEffect(() => {
if (rootRef.current) {
@@ -266,6 +294,7 @@ export function CalendarWeekView({
const key = dayKey(day);
if (key === visibleKeyRef.current) return;
visibleKeyRef.current = key;
setVisibleDayKey(key);
onVisibleDateChange?.(day);
});
}, [colWidth, colCount, days, onVisibleDateChange]);
@@ -331,14 +360,34 @@ export function CalendarWeekView({
{hasAllDay && (
<div className="flex border-b border-border">
<div
className={cn(gutterClass, "text-[10px] text-muted-foreground p-1 text-end")}
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
className={cn(gutterClass, "text-[10px] text-muted-foreground p-1 text-end flex flex-col justify-between")}
style={{ height: allDayHeight }}
>
{t("events.all_day")}
<span>{t("events.all_day")}</span>
{isExpandable && (
<button
type="button"
aria-label="all-day-expand"
aria-expanded={isAllDayExpanded}
onClick={() => setIsAllDayExpanded((prev) => !prev)}
className="self-end mt-auto text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-0.5 rounded px-1 py-0.5 hover:bg-muted transition-colors cursor-pointer"
title={isAllDayExpanded ? t("events.show_less") : t("events.show_more")}
>
{isAllDayExpanded ? (
<span>▲</span>
) : (
<>
<span>+{maxContentRows - DEFAULT_ALL_DAY_MAX_ROWS}</span>
<span>▼</span>
</>
)}
</button>
)}
</div>
<div
className="relative grid border-s border-border"
style={{ ...columnsStyle, minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
data-testid="all-day-grid"
className="relative grid border-s border-border overflow-hidden"
style={{ ...columnsStyle, height: allDayHeight }}
>
{days.map((day) => (
<div
@@ -372,54 +421,40 @@ export function CalendarWeekView({
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
/>
</div>
);
})}
</div>
{/* Task chips in all-day area */}
{taskRowCount > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: allDayRowCount * 24 + 2 }}>
{days.map((day, dayIndex) => {
const key = format(day, "yyyy-MM-dd");
const dayTasks = tasksByDay.get(key) || [];
return dayTasks.map((task, taskIndex) => {
const isCompleted = task.progress === "completed";
const cal = calendars.find(c => task.calendarIds[c.id]);
const color = cal?.color || "#3b82f6";
return (
<div
key={`task-${task.id}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
width: `calc(${(1 / colCount) * 100}% - 2px)`,
top: taskIndex * 24,
height: 20,
}}
>
<div
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate flex items-center gap-1 cursor-pointer hover:opacity-80"
style={{ backgroundColor: `${color}20`, borderLeft: `3px solid ${color}` }}
onClick={() => onToggleTaskComplete?.(task)}
>
<span className={cn(
"w-2.5 h-2.5 rounded-full border flex-shrink-0 flex items-center justify-center",
isCompleted ? "bg-success border-success" : "border-current"
)}>
{isCompleted && <Check className="h-2 w-2 text-white" />}
</span>
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
{task.title}
</span>
</div>
</div>
);
});
})}
</div>
)}
{/* Task chips in all-day area, stacked under events of their day (#1107, #1270) */}
<div className="absolute inset-0 pointer-events-none">
{days.map((day, dayIndex) => {
const key = format(day, "yyyy-MM-dd");
const dayTasks = tasksByDay.get(key) || [];
const baseRow = dayEventRows[dayIndex];
return dayTasks.map((task, taskIndex) => (
<div
key={`task-${task.id}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
width: `calc(${(1 / colCount) * 100}% - 2px)`,
top: (baseRow + taskIndex) * 24 + 2,
height: 20,
}}
>
<CalendarTaskChip
task={task}
calendar={calendars.find(c => task.calendarIds[c.id])}
onToggleComplete={onToggleTaskComplete}
onSelect={onSelectTask}
/>
</div>
));
})}
</div>
</div>
</div>
)}
@@ -538,6 +573,7 @@ export function CalendarWeekView({
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
draggable
/>
<div
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Calendar as CalendarIcon } from "lucide-react";
import { X, Loader2, Calendar as CalendarIcon } from "@/components/icons";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useCalendarStore } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -6,7 +6,7 @@ import {
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
import { Plus, CalendarDays, CheckSquare, Clock } from "lucide-react";
import { Plus, CalendarDays, CheckSquare, Clock } from "@/components/icons";
interface Position {
x: number;
+50 -34
View File
@@ -5,10 +5,11 @@ import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { format } from "date-fns";
import { Users } from "lucide-react";
import { getParticipantCount } from "@/lib/calendar-participants";
import { Users } from "@/components/icons";
import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { useSettingsStore } from "@/stores/settings-store";
import { readableTextOn } from "@/lib/color-transform";
interface EventCardProps {
event: CalendarEvent;
@@ -24,6 +25,8 @@ interface EventCardProps {
continuesAfter?: boolean;
className?: string;
style?: CSSProperties;
/** The user's calendar addresses, to tell whether they declined the event. */
currentUserEmails?: string[];
}
function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string {
@@ -42,6 +45,24 @@ function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
}
// Events are solid blocks of their calendar colour; declined and cancelled
// ones drop the fill and keep a 1px border in it (repos/branding/APP.md).
// Everything is drawn with box-shadow, so the selection ring joins the list
// here and the browser's focus outline stays free.
function eventFillStyle(
color: string,
{ inactive, separated, selected }: { inactive: boolean; separated?: boolean; selected?: boolean },
): CSSProperties {
const shadows: string[] = [];
if (selected) shadows.push("0 0 0 2px var(--color-primary)");
else if (separated) shadows.push("0 0 0 1px var(--color-background)");
if (inactive) shadows.push(`inset 0 0 0 1px ${color}`);
const fill: CSSProperties = inactive
? { backgroundColor: "var(--color-background)", color: `color-mix(in srgb, ${color} 55%, var(--color-foreground))` }
: { backgroundColor: color, color: readableTextOn(color) };
return shadows.length > 0 ? { ...fill, boxShadow: shadows.join(", ") } : fill;
}
function parseDuration(duration: string | undefined): number {
if (!duration) return 0;
let totalMinutes = 0;
@@ -60,9 +81,9 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
const el = document.createElement("div");
el.style.cssText = `
position: fixed; top: -9999px; left: 0;
padding: 6px 12px; border-radius: 6px;
background: ${color}40; border-left: 3px solid ${color};
color: ${color}; font-size: 12px; font-weight: 500;
padding: 6px 12px; border-radius: 2px;
background: ${color}; color: ${readableTextOn(color)};
font-size: 12px; font-weight: 500;
max-width: 240px; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; pointer-events: none; z-index: 9999;
`;
@@ -71,7 +92,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
return el;
}
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesAfter = false, className, style }: EventCardProps) {
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style, currentUserEmails }: EventCardProps) {
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
@@ -89,9 +110,13 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
};
const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`;
// iTIP CANCEL marks the attendee's copy with status "cancelled" instead of
// deleting it (#572) - render it struck through and dimmed.
// deleting it (#572), and a declined invitation stays on the calendar too
// (#1110) - render both struck through and dimmed.
const isCancelled = event.status === "cancelled";
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}${isCancelled ? `, ${t("detail.cancelled")}` : ""}`;
const isDeclined = !isCancelled && isDeclinedByUser(event, currentUserEmails);
const isInactive = isCancelled || isDeclined;
const statusLabel = isCancelled ? t("detail.cancelled") : isDeclined ? t("participants.declined") : null;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}${statusLabel ? `, ${statusLabel}` : ""}`;
const handleDragStart = useCallback((e: DragEvent) => {
e.stopPropagation();
@@ -134,21 +159,15 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
aria-label={ariaLabel}
{...dragProps}
className={cn(
"flex items-center gap-1 w-full text-start text-xs px-1 py-0.5 rounded truncate",
"flex items-center w-full text-start text-xs font-medium px-1.5 py-0.5 rounded-xs truncate",
"min-h-[44px] sm:min-h-0",
"hover:opacity-80 transition-opacity",
isSelected && "ring-2 ring-primary",
"hover:opacity-90 transition-opacity",
isBeingDragged && "opacity-50",
isCancelled && !isBeingDragged && "opacity-60",
className
)}
style={{ backgroundColor: `${color}20`, color, ...style }}
style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected }), ...style }}
>
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
<span className={cn("truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
<span className={cn("truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
</button>
);
}
@@ -163,22 +182,20 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
aria-label={ariaLabel}
{...dragProps}
className={cn(
"w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
continuesAfter && "rounded-r-sm",
continuesAfter && "pe-2",
isSelected && "ring-2 ring-primary",
continuesBefore && "rounded-s-none",
continuesAfter && "rounded-e-none pe-2",
isBeingDragged && "opacity-50",
isCancelled && !isBeingDragged && "opacity-60",
className
)}
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected }), ...style }}
>
<div className="flex items-center gap-1 min-w-0">
{showTimeInMonthView && !event.showWithoutTime && (
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
<span className="flex-shrink-0 font-normal opacity-85">{format(startDate, timeFmt)}</span>
)}
<span className={cn("truncate font-medium", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
<span className={cn("truncate font-medium", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
</div>
</button>
);
@@ -194,24 +211,23 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{...dragProps}
data-calendar-event
className={cn(
"w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
isCancelled && !isBeingDragged && "opacity-60",
className
)}
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }}
style={{ ...eventFillStyle(color, { inactive: isInactive, separated: true, selected: isSelected }), ...style }}
>
<div className={cn("font-medium truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</div>
{!event.showWithoutTime && (
<div className="opacity-80 text-[10px]">
<div className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</div>
{/* Shorter blocks have no room for a second line at the 60px hour. */}
{!event.showWithoutTime && durationMinutes >= 45 && (
<div className="opacity-85 text-[10.5px]">
{timeString}
</div>
)}
{getParticipantCount(event) > 0 && (
<div
className="flex items-center gap-0.5 opacity-70 text-[10px]"
className="flex items-center gap-0.5 opacity-85 text-[10.5px]"
title={t("participants.count", { count: getParticipantCount(event) })}
>
<Users className="w-3 h-3" />
+1 -1
View File
@@ -14,7 +14,7 @@ import {
Link as LinkIcon,
CalendarArrowUp as CalendarLinkIcon,
Trash2,
} from "lucide-react";
} from "@/components/icons";
import type { CalendarEvent } from "@/lib/jmap/types";
import { buildCalendarPath } from "@/lib/deep-links";
import { useCopyLink } from "@/hooks/use-copy-link";
+1 -1
View File
@@ -8,7 +8,7 @@ import { LinkifiedText } from "@/components/ui/linkified-text";
import {
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
Pencil, Trash2, Copy, Send, Check,
} from "lucide-react";
} from "@/components/icons";
import { format, isSameDay } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
+1 -1
View File
@@ -5,7 +5,7 @@ import { useTranslations, useLocale } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LinkifiedText } from "@/components/ui/linkified-text";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "@/components/icons";
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
+3 -2
View File
@@ -3,7 +3,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "@/components/icons";
import { format } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -12,6 +12,7 @@ import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { apiFetch } from "@/lib/browser-navigation";
import { explainServerAuthError } from "@/lib/server-auth-status";
import { IS_LITE } from "@/lib/lite";
interface ICalImportModalProps {
@@ -137,7 +138,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || t("url_fetch_failed"));
throw new Error(explainServerAuthError(response.status, data.error, t("url_fetch_failed")));
}
const blob = await response.blob();

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