Merge branch 'main' into feat/stalwart-1.0

# Conflicts:
#	CONTRIBUTING.md
#	LICENSE
#	README.md
#	app/api/calendar-agenda/route.ts
#	app/api/wopi/files/[fileId]/contents/route.ts
#	app/api/wopi/launch/route.ts
#	components/calendar/calendar-app.tsx
#	components/calendar/calendar-day-view.tsx
#	components/calendar/calendar-month-view.tsx
#	components/calendar/calendar-week-view.tsx
#	components/calendar/event-modal.tsx
#	components/contacts/contact-detail.tsx
#	components/email/email-composer.tsx
#	components/email/email-list.tsx
#	components/files/file-browser.tsx
#	components/files/files-app.tsx
#	components/files/folder-tree-sidebar.tsx
#	components/filters/filter-rule-modal.tsx
#	components/mail/mail-app.tsx
#	hooks/use-mailbox-drop.ts
#	integration/run-tests.sh
#	lib/__tests__/calendar-agenda-route.test.ts
#	lib/__tests__/jmap-draft-lifecycle.test.ts
#	lib/__tests__/web-push.test.ts
#	lib/account-state-manager.ts
#	lib/jmap/client-interface.ts
#	lib/jmap/client.ts
#	lib/jmap/types.ts
#	lib/message-list-order.ts
#	lib/sieve/generator.ts
#	lib/sieve/parser.ts
#	lib/telemetry/payload.ts
#	lib/thread-utils.ts
#	lib/unified-mailbox.ts
#	lib/web-push.ts
#	lib/wopi/token.ts
#	locales/ar/common.json
#	locales/ca/common.json
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/fa/common.json
#	locales/fr/common.json
#	locales/he/common.json
#	locales/hu/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/mn/common.json
#	locales/nb/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ro/common.json
#	locales/ru/common.json
#	locales/sk/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/zh-TW/common.json
#	locales/zh/common.json
#	scripts/lite/lib.mjs
#	stores/__tests__/calendar-store-synthetic-ids.test.ts
#	stores/__tests__/email-store-shared-folder-keywords.test.ts
#	stores/__tests__/email-store-unified-actions.test.ts
#	stores/__tests__/file-store.test.ts
#	stores/email-store.ts
#	stores/file-store.ts
#	stores/message-list-tabs-store.ts
This commit is contained in:
Linus Rath
2026-10-12 08:35:08 +02:00
674 changed files with 74850 additions and 11625 deletions
+11
View File
@@ -14,5 +14,16 @@ TODO.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/
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
+32 -2
View File
@@ -28,7 +28,9 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Offer several JMAP servers on the login form. JSON array; each entry needs
# id, label, and url. "domains" and a per-server "oauth" block are optional.
# Prefer configuring this from the admin dashboard - the env form exists for
# stateless deployments.
# stateless deployments. An "oauth" block with both "clientId" and
# "buttonLabel" gives that server its own sign-in button on the login page,
# even while OAuth is off globally.
# JMAP_SERVERS=[{"id":"eu","label":"Europe","url":"https://eu.example.com","domains":["example.com"]},{"id":"us","label":"US","url":"https://us.example.com","oauth":{"clientId":"webmail-us"}}]
# Pick the server automatically from the domain of the address the user types,
@@ -88,7 +90,9 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# 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.
# to use the defaults: openid email profile. offline_access is not among them;
# add it if your IdP (e.g. Authelia) only issues refresh tokens for it,
# otherwise sessions end when the access token expires.
# OAUTH_SCOPES=openid email profile offline_access
# Append scopes instead of replacing them. Use this when your IdP needs one
@@ -148,6 +152,12 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# error than a mid-request EROFS). Pair with `:ro` on the config-volume mount.
# ADMIN_CONFIG_READONLY=true
#
# Optional existing PEM PKCS#8 Ed25519 private key used to sign managed plugin
# bundles. Bulwark reads this exact path without modifying it. Use a runtime
# secret mount such as a systemd credential, Docker secret, or Kubernetes Secret.
# When unset, Bulwark loads or creates ADMIN_CONFIG_DIR/plugin-signing.key.
# PLUGIN_SIGNING_KEY_FILE=/run/secrets/plugin-signing-key
#
# Legacy: a single dir containing both config and state. Honoured if neither
# of the split variables is set. New installs should use the split vars.
# ADMIN_DATA_DIR=./data/admin
@@ -292,6 +302,12 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
# URL for the company website link on the login page.
LOGIN_WEBSITE_URL=https://bulwarkmail.org
# Where users get the source code of this build, linked from Settings >
# About. Bulwark is AGPL-3.0-only: if you run a modified build, set this to
# the source of exactly what you deploy (your fork, at the deployed commit).
# Unset links to the upstream repository. Only http(s) URLs are used.
# SOURCE_CODE_URL=https://git.example.com/acme/webmail/tree/v1.2.3
# Cap the login logo's rendered size. Any CSS length ("120px", "8rem").
# Unset means the logo renders at its natural size.
# LOGIN_LOGO_MAX_HEIGHT=96px
@@ -306,6 +322,11 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# still shows it regardless of this setting.
# LOGIN_SHOW_TOTP=false
#
# Offer "Sign in with an access token" (default false). For JMAP servers
# that authenticate API tokens as Bearer tokens instead of passwords, such
# as Fastmail (JMAP_SERVER_URL=https://api.fastmail.com).
# LOGIN_SHOW_TOKEN_LOGIN=true
#
# Hide the version number, so it isn't disclosed to unauthenticated visitors.
# LOGIN_SHOW_VERSION=false
@@ -370,6 +391,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.
@@ -450,6 +475,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
+21
View File
@@ -0,0 +1,21 @@
## What and why
<!-- What changes for the user, and why. A few sentences are enough. -->
## Related issues
<!-- "Fixes #123". For a change that needed agreement first, link the issue where the approach was agreed. -->
## Checklist
- [ ] The change fits [What fits in Bulwark](https://bulwarkmail.org/docs/development/contributing#what-fits-in-bulwark) (core, not plugin material)
- [ ] It needed no issue first, or the approach was agreed in the linked issue (over ~500 lines excluding locales and tests, a new runtime dependency, or security-sensitive code: [details](https://bulwarkmail.org/docs/development/contributing#before-you-write-code))
- [ ] `npm run typecheck && npm run lint && npx vitest run` pass, and new logic has tests
- [ ] New user-facing text goes through translations
- [ ] Works in Bulwark Lite, or is hidden there
- [ ] UI changes checked at phone width and in the light and dark theme
- [ ] Documentation updated in the [website repository](https://github.com/bulwarkmail/website/tree/main/docs), or not needed
## Screenshots
<!-- For UI changes: desktop and phone width. -->
+2 -2
View File
@@ -1,4 +1,4 @@
name: Deploy demo
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
@@ -18,7 +18,7 @@ name: Deploy demo
on:
workflow_run:
workflows: ["Publish Docker Image on Release"]
workflows: ["Docker: Release image"]
types: [completed]
workflow_dispatch:
inputs:
+27 -12
View File
@@ -1,12 +1,14 @@
name: Publish Lite Docker Image
name: "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 Lite E2E 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
# - pull request: E2E 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:
@@ -34,7 +36,8 @@ on:
workflow_dispatch:
jobs:
smoke:
e2e:
name: E2E (container)
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
@@ -52,13 +55,13 @@ jobs:
context: .
file: Dockerfile.lite
load: true
tags: bulwark-lite:smoke
tags: bulwark-lite:e2e
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
cache-from: type=gha,scope=lite-e2e
cache-to: type=gha,mode=max,scope=lite-e2e
# Read-only root, like a hardened Kubernetes pod: nginx only needs /tmp
# and the directory the entrypoint renders its config into.
@@ -67,7 +70,7 @@ jobs:
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
bulwark-lite:e2e
for i in $(seq 1 30); do
curl -fsS http://localhost:4173/lite-build.json && exit 0
sleep 1
@@ -80,7 +83,7 @@ jobs:
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
bulwark-lite:e2e
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; }
@@ -100,7 +103,7 @@ jobs:
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Run smoke test against the container
- name: Run E2E test against the container
env:
LITE_SMOKE_BASE_URL: http://localhost:4173
LITE_SMOKE_EXPECT_CONNECT_SRC: https://mail.example.com
@@ -114,18 +117,21 @@ jobs:
if: failure()
uses: actions/upload-artifact@v4
with:
name: lite-container-smoke-report
name: lite-container-e2e-report
path: playwright-report
retention-days: 7
publish:
needs: smoke
name: Publish
needs: e2e
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:
@@ -161,6 +167,7 @@ jobs:
# 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: .
@@ -176,3 +183,11 @@ jobs:
- 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
+39 -3
View File
@@ -1,5 +1,11 @@
name: Publish Docker Image on Release
name: "Docker: Release image"
# `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]
@@ -10,6 +16,7 @@ env:
jobs:
build:
name: Build (${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }})
strategy:
fail-fast: false
matrix:
@@ -71,11 +78,14 @@ jobs:
retention-days: 1
merge:
name: Merge manifest
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Download digests
@@ -101,7 +111,7 @@ jobs:
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.event_name != 'release' || !github.event.release.prerelease }}
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,10 +126,22 @@ 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:
name: Build locale-prefixed (${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }})
strategy:
fail-fast: false
matrix:
@@ -181,11 +203,14 @@ jobs:
retention-days: 1
merge-always:
name: Merge locale-prefixed manifest
runs-on: ubuntu-latest
needs: build-always
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
@@ -212,7 +237,7 @@ jobs:
flavor: |
suffix=-always,onlatest=true
tags: |
type=raw,value=latest,enable=${{ github.event_name != 'release' || !github.event.release.prerelease }}
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 +252,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
+25 -4
View File
@@ -1,4 +1,4 @@
name: Publish Docker Image
name: "Docker: Branch image"
on:
push:
@@ -23,20 +23,25 @@ on:
jobs:
prepare:
name: Prepare
runs-on: ubuntu-latest
outputs:
image_name: ${{ steps.set.outputs.image_name }}
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:
name: Build (${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }})
needs: prepare
strategy:
fail-fast: false
@@ -99,11 +104,14 @@ jobs:
retention-days: 1
merge:
name: Merge manifest
runs-on: ubuntu-latest
needs: [prepare, build]
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Download digests
@@ -139,5 +147,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
+73
View File
@@ -0,0 +1,73 @@
name: "CI: Integration tests"
# Runs the dockerised webmail + Stalwart Playwright suite in integration/
# (see integration/README.md). It takes several minutes, so unlike the vitest
# job in tests.yml it does not run on every push. It runs:
#
# - every night on main,
# - on a pull request that carries the "integration" label (and again on
# each push to it while the label stays on),
# - on demand: gh workflow run integration.yml
#
# A fresh runner means a fresh Stalwart volume, so the declarative account
# bootstrap always applies. Specs pinned with test.fail for known server bugs
# count as passed when they fail as expected; only an unexpected result fails
# the job.
on:
schedule:
- cron: "17 2 * * *"
workflow_dispatch:
pull_request:
types: [opened, reopened, synchronize, labeled]
permissions:
contents: read
jobs:
playwright:
name: Playwright
# On pull requests, run only when the "integration" label is on. A labeled
# event for some other label must not start (or cancel) a run.
if: >-
github.event_name != 'pull_request' ||
(github.event.action == 'labeled' && github.event.label.name == 'integration') ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'integration'))
runs-on: ubuntu-latest
timeout-minutes: 60
# Job-level, so a skipped run never cancels a real one.
concurrency:
group: integration-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
# The Playwright container runs the suite from this checkout, so it
# needs @playwright/test in node_modules.
run: npm ci
- name: Run integration suite
run: bash integration/run-tests.sh
- name: Show container logs
if: failure()
working-directory: integration
run: docker compose logs --no-color --timestamps
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: integration-report
path: |
integration/playwright-report/
integration/test-results/
retention-days: 14
+45 -11
View File
@@ -1,15 +1,22 @@
name: Publish Standalone Tarball on Release
name: "Standalone: Release tarball"
# 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:
name: Attach to release
types: [published]
workflow_dispatch:
permissions:
contents: write
contents: read
jobs:
build:
name: Build (${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
@@ -52,17 +59,44 @@ 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:
name: Attach to 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
+53 -21
View File
@@ -1,4 +1,4 @@
name: Build Static Lite
name: "Lite: Static bundle"
# Bulwark Lite: the static export of the webmail (see docs in README.md,
# "Static Lite build"). Runs the build on every push as a regression check,
@@ -7,16 +7,18 @@ 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).
on:
release:
name: Attach to release
types: [published]
push:
branches: [main]
@@ -42,10 +44,11 @@ on:
default: false
permissions:
contents: write
contents: read
jobs:
build:
name: Build (${{ matrix.target }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
@@ -98,22 +101,50 @@ 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
smoke:
release:
name: Attach to 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
e2e:
name: E2E (static)
# Boots the demo export on a plain static server and drives it with
# Playwright: login, open a thread, switch surfaces, reload a deep link.
# No JMAP server involved, so it is cheap and deterministic.
@@ -136,7 +167,7 @@ jobs:
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
# prepare.mjs prunes every test tree in the checkout; the smoke suite
# prepare.mjs prunes every test tree in the checkout; the E2E suite
# lives under scripts/lite/smoke, which it leaves alone.
- name: Build demo export
env:
@@ -146,18 +177,19 @@ jobs:
LITE_LOCALES: "en,de"
run: npm run build:lite
- name: Run smoke test
- name: Run E2E test
run: npm run test:lite-smoke
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: lite-smoke-report
name: lite-e2e-report
path: playwright-report
retention-days: 7
smoke-stalwart:
e2e-stalwart:
name: E2E (Stalwart)
# The Stalwart Application bundle, served by scripts/lite/serve.mjs
# --stalwart (an emulation of how Stalwart serves Applications) from the
# zip itself, under two prefixes.
@@ -189,13 +221,13 @@ jobs:
LITE_LOCALES: "en,de"
run: npm run build:lite
- name: Run smoke test
- name: Run E2E test
run: npm run test:lite-smoke:stalwart
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: lite-smoke-stalwart-report
name: lite-e2e-stalwart-report
path: playwright-report
retention-days: 7
+2 -2
View File
@@ -1,4 +1,4 @@
name: Tests
name: "CI: Unit tests"
on:
push:
@@ -6,7 +6,7 @@ on:
jobs:
vitest:
name: vitest
name: Vitest
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
+471
View File
@@ -1,5 +1,476 @@
# Changelog
## 1.14.1 (2026-10-11)
1.14.1 fixes plain-text and unstyled messages showing on a gray background in dark mode after the 1.14.0 dark mode rewrite.
Thank you for your donations:
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
**One-time**
- Anonymous
- Anonymous
- Anonymous
**Monthly**
- [@naokton](https://github.com/naokton)
- [@yerTools](https://github.com/yerTools)
- [@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)
- [@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
- **Mail**: In dark mode, plain-text and unstyled messages use the app's background again instead of a gray box
## 1.14.0 (2026-10-11)
1.14.0 adds Mountain View, an optional layout with a top bar and a wide search field, and gives the Files app and the calendar a new layout. Dark mode now recolours messages instead of inverting them, so brand colours stay as sent. Push can be turned on for several accounts at once, and the account list can be backed up encrypted and restored on another device. The composer got a long round of send fixes. With several accounts open, batch actions no longer hit another account's message that happens to share an id with the selected one.
Thank you for your donations:
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
**One-time**
- Anonymous
- Anonymous
- Anonymous
**Monthly**
- [@naokton](https://github.com/naokton)
- [@yerTools](https://github.com/yerTools)
- [@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)
- [@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
- **UI**: Mountain View is an optional layout, chosen under Settings > Layout > Interface layout. On desktop it has a top bar with a wide search field, an apps grid and the account avatar in place of the left rail, a Compose pill, a docked compose window, an account popover with unread counts per account, and j/k, x and g shortcuts. Calendar, Contacts, Files and Settings use the same top bar. The default layout is unchanged (#1168, thanks @lucamzanon)
- **Files**: The Files app is laid out like Contacts and Calendar: a sidebar with a New menu, the folder tree, Starred, Recent and Shared with me, a header with breadcrumbs, search, sort and a list/grid switch, a details panel, right-click menus in the sidebar and arrow-key navigation. On touch, a tap opens and a long press selects. Folders in the tree can be renamed and deleted, recent files open in their folder, and the view, sort and hidden-files settings now apply
- **Files**: Image previews in Files and in attachments zoom with the wheel or a pinch, pan by dragging or with the arrow keys, switch between fit and full size on double-click, and close on a click beside the image
- **Files**: Uploads show as a toast with their progress and a Cancel button. The large file warning is gone
- **Calendar**: On wide screens, clicking or dragging over empty time opens a quick editor next to the slot, and clicking an event opens a card with edit, delete and more actions. Busy month cells show "+N more", the agenda lists each day as a row with the date beside its events, and the dates in the week header open that day. Calendar lists in the sidebar fold away and show their options on hover. Phones keep the full editor
- **Push**: The notification settings list every connected account and turn push on or off for several of them at once, with a single browser permission prompt (#1054, thanks @lucamzanon)
- **Accounts**: Users with several accounts can save their account list on the webmail server, encrypted in the browser with a password they choose, and restore it on another device. No mailbox passwords are stored. Admins turn it on with "Account List Backup" under Admin > Policy (#1201, thanks @lucamzanon)
- **Filters**: The folder context menu runs a saved rule on the messages already in that folder (#1166)
- **Composer**: Schedule send offers tomorrow at 8:00, tomorrow at 13:00 and next Monday at 8:00 as one-click times
- **Settings**: The language picker has a search field and flags, and every date format and region option shows a live sample
- **Plugins**: A plugin can run a message-list search with a JMAP Email filter (#1024)
- **Plugins**: Calendar event actions get the event's uid and attendees, and the form-save hook now fires and can change the meeting link that is saved with the event (#1202, thanks @siennathesane)
### Changes
- **Mail**: Dark mode recolours messages instead of inverting them. Light backgrounds are darkened in their own hue, brand colours and dark fills stay as sent, and text only changes when it lacks contrast. A message's own dark mode styles follow the app theme instead of the system's
- **Mail**: A folder opened before shows its messages at once and refreshes them in the background. A failed load leaves them on screen (#1195, thanks @lucamzanon)
- **Composer**: On a phone, the formatting toolbar folds away behind a Formatting button and its buttons are larger. The send options sit together in one menu
- **Files**: File sizes are rounded the way people read them, like 67 KB or 2.1 MB
### Fixes
- **Mail**: In views that merge several accounts, selecting a message no longer also selects a message with the same id in another account. Batch delete, move, archive, copy, mark read, tag and spam act only on the ticked messages, each through its own account. Before, a permanent delete could remove the other account's message (#1061, thanks @lucamzanon)
- **Mail**: A message with two spaces in a row in its subject no longer freezes the tab (#1193, thanks @dealerweb)
- **Mail**: Blocking remote content no longer drops a message's style sheets, so messages keep their layout before images are loaded
- **Mail**: The list shows no paperclip on a message that only has inline images
- **Mail**: The "via" badge is hidden when it repeats the sender's name, and a draft shows one Edit button
- **Send**: Signed and encrypted messages reach Bcc recipients (#1206)
- **Send**: After a plugin send, such as S/MIME, the composer closes, and a failed plugin send keeps the draft (#1207)
- **Send**: A failed send removes its unsent copy from Drafts and explains the failure in plain words. A send while offline says so and keeps the message
- **Send**: Typed addresses are split on commas, and a message with an invalid address is not sent. The address field marks which ones to fix
- **Send**: Undo send disappears once the delay is over and says so when an undo fails
- **Send**: Discard deletes the draft, also one reopened from Scheduled, and closing an autosaved draft no longer asks about unsaved changes
- **Send**: The missing subject warning comes once per message, and the scheduled time shows its date, rounded to the minute
- **Composer**: Tabbing into a reply or forward keeps the quoted message, and tabbing into a new message starts at the top of the body
- **Composer**: A reply opens with the caret in the body, and the composer is titled Reply, Reply All or Forward instead of New Message
- **Composer**: A reopened draft takes its text from the message body only. An attached .csv or .txt no longer turns up as the message text (#1213, thanks @shukiv)
- **Composer**: A long attachment list scrolls instead of pushing the Send button out of view (#1212, thanks @shukiv)
- **Composer**: The plain-text part of sent mail marks quotes and list items, and the signature has one separator
- **Composer**: Enter adds the highlighted suggestion instead of the typed text, and the suggestions close once the address is added and line up with the typed text. On a phone they span the whole row and show the name above the address
- **Composer**: Body text no longer shows through the pinned formatting toolbar (#1200, thanks @lucamzanon)
- **Composer**: On a phone, sending a new message returns to the message list, and the Continue draft banner no longer flashes after a send
- **Composer**: Bitwarden no longer offers logins in the recipient fields, the mail search box or the event participant field
- **Login**: A remembered sign-in survives a server timeout or an origin switch during restore instead of signing out (#1167)
- **Login**: Signing out of one account keeps the other accounts, and a reload with only accounts awaiting sign-in shows the login page instead of a connection error (#1201, thanks @lucamzanon)
- **Login**: A reload without "Remember me" no longer reports an expired session
- **Unified**: Switching to another account keeps the unified mailbox on (#1144, thanks @lucamzanon)
- **Push**: Push is renewed for every connected account, not only the active one, so accounts no longer stop notifying after seven days. The all-accounts panel shows each account's real state and why an account failed (#1054, thanks @lucamzanon)
- **Filters**: Forward is hidden in filter rules when the server allows no redirects (#1208)
- **Calendar**: Save is blocked while a plugin's form-save hook runs, so a double press no longer creates the event twice (#1202, thanks @siennathesane)
- **Calendar**: Participant suggestions are no longer cut off by the event form
- **Files**: Canceling an upload stops the file in flight and says the upload was canceled instead of counting every file as done
- **Files**: A drag selection keeps its items after letting go, and Files shows a loading state instead of "No files yet" before the first listing
- **Files**: Read-only documents open in ONLYOFFICE without the -103 warning (#1187)
- **Contacts**: The contact popover shows the sender's name when the saved contact has none, and Contacts shows placeholders instead of "No contacts yet" while loading
- **UI**: The close and back buttons in the composer and conversation view have names for screen readers, and the composer toolbar buttons are labelled
- **UI**: Reply All stays on one line in the conversation view, and the welcome banner gives touch tips on a phone
- **Translations**: The new composer, send, Files and filter strings are translated
## 1.13.0 (2026-10-07)
1.13.0 turns the vacation settings into an Out of Office page that can also forward mail, lets filter rules run only for a date range, and copies messages into another connected account. Files from the Files app can be attached in the composer, tags can be set on many messages at once and folders moved in bulk. Messages whose sender can't be verified now carry a warning. It also stops one account's settings and templates from carrying over to the next account signed in on the same browser.
Thank you for your donations:
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
**One-time**
- Anonymous
- Anonymous
- Anonymous
**Monthly**
- [@naokton](https://github.com/naokton)
- [@yerTools](https://github.com/yerTools)
- [@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)
- [@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
- **Settings**: Signing out and then signing in with another account in the same browser no longer shows the previous account's settings, tags and templates, and no longer saves them to the new account's synced settings (#1185)
### Features
- **Mail**: A message whose sender fails the From domain's checks, or that passes neither SPF nor DKIM, shows a warning above the message and a badge next to the sender. "Always trust this sender" is not offered for it
- **Mail**: "Copy to account..." in the message menu files a copy in a folder of another connected account and leaves the original where it is. Copies keep their original date (#1150, thanks @lucamzanon)
- **Mail**: The selection toolbar and the right-click menu put a tag on, or take it off, every selected message at once (#1077)
- **Out of office**: The vacation settings are now an Out of Office page: the period on top, then the automatic reply and forwarding, each with its own switch. Forwarding works with or without the reply, never forwards spam and can keep a copy in the inbox. The reply can go to all senders, internal senders only or external senders only (#1152, thanks @dealerweb)
- **Filters**: A rule can be limited to a period, and the rule list shows whether it is scheduled, active or expired (#1152, thanks @dealerweb)
- **Filters**: The new "All messages" condition matches every message (#1154, thanks @dealerweb)
- **Composer**: "Attach from Files" attaches files from the Files app, including files shared with you, without downloading and uploading them again (#1179)
- **Composer**: Typing @ in the message body offers the To and Cc recipients and inserts the chosen one's first name. Bcc recipients are never offered. "Mention recipients with @" in the composer settings turns it off (#1134, thanks @dealerweb)
- **Folders**: The folder settings can select several folders, with Shift+click for a range, and move them together under another folder or to the top level (#1173)
- **Calendar**: The day and week views can be limited to working hours, and the week view can leave out non-working days. Events outside the visible hours are counted at the top and bottom of each day and open from there, and a button in the view shows all hours (#1164)
- **Login**: `LOGIN_SHOW_TOKEN_LOGIN=true` offers "Sign in with an access token", for JMAP servers that take API tokens instead of passwords, such as Fastmail
- **Login**: A server in `JMAP_SERVERS` whose `oauth` block has both a `clientId` and a `buttonLabel` gets its own sign-in button on the login page, even while OAuth is off globally. This allows a "Sign in with Google" button for a Gmail bridge next to servers that use passwords (#1135, thanks @lucamzanon)
- **UI**: The browser tab title names the active account, as in "Inbox - alice@example.com - Webmail", and Settings, Calendar, Contacts and Files set their own title. Turning off "Subject in Tab Title" under Admin > Policy keeps message subjects out of the title, and with it out of the browser history (#1186, thanks @oguzyilmaznet)
- **Unified**: While logins reconnect after a page load, the "All accounts" header shows how many are already counted, such as "4/10" (#1140, thanks @lucamzanon)
- **Push**: A message that reaches several of your accounts rings once. For 30 seconds after an alert, notifications from your other accounts arrive silently (#1141, thanks @lucamzanon)
- **Server**: `SOURCE_CODE_URL` points the source code link in Settings > About at the code of a modified build, as AGPL section 13 asks of anyone running one (#1161)
### Changes
- **Calendar**: The day and week views show 08:00 to 20:00 by default. "Limit visible hours" in the calendar settings changes the range or turns it off (#1164)
- **Tags**: Tag views and their counts leave out messages in Trash and Spam (#1156)
- **Mail**: In a shared mailbox, the Move menus list that mailbox's folders first and your own folders after them, under your account name (#1149)
- **Dependencies**: sharp 0.35.5 and source-map-js 1.2.2 (npm audit)
- **Docs**: CONTRIBUTING.md explains what belongs in core and what in a plugin, and which changes need an agreed issue before a pull request
### Fixes
- **Mail**: Attachments, inline images and .eml downloads of messages in shared folders are fetched from the account that owns them. On Cyrus they failed with "failed to find blob by id" (#1188, thanks @VasilisNtovolos)
- **Mail**: Archive creates an Archive folder when the account has none, instead of only reporting that it is missing (#1176, thanks @cybersmurf)
- **Mail**: Opening the webmail starts in the inbox instead of a folder from an earlier visit, such as Trash
- **Mail**: Opening another folder, tag, account or search starts the message list at the top instead of where the last one was scrolled to (#1138, thanks @dealerweb)
- **Mail**: Previews no longer start with a style sheet ("@media screen and ...") in the message list and in push notifications (#1136, thanks @lucamzanon)
- **Search**: Searching from the unified All mail view finds sent mail too
- **Send**: A draft saved without a subject reopens with an empty subject, so Send warns about it again instead of sending "(No Subject)" (#1189)
- **Send**: When the server does not confirm that a message was sent, the draft is kept and the composer asks you to check Sent before sending again (#1019, thanks @lucamzanon)
- **Send**: A message the server refuses shows the server's reason instead of "Invalid reference to non-existing object"
- **Send**: A forward sent from another account's address no longer fails with `blobNotFound`. Its attachments and inline images are copied to the sending account first (#1142, thanks @lucamzanon)
- **Push**: Push turns itself back on when the browser lost its subscription, retrying at most every 15 minutes (#1147, thanks @lucamzanon)
- **Push**: The notification settings check push again when you come back to the tab, so push turned off in the browser or another tab no longer shows as on (#1148, thanks @lucamzanon)
- **Push**: When the new message cannot be read, the notification no longer announces another unread message from the Inbox in its place, and a failed lookup shows a generic notification instead of none (#1041, thanks @lucamzanon)
- **Filters**: "Stop processing further rules" also stops after "Delete silently" and "Reject", so later rules no longer file the message. Rules whose name contains double spaces, a tab or a trailing space no longer gain a copy on every save, and the copies earlier saves left are removed (#1153, thanks @dealerweb)
- **Filters**: The forwarding limit counts the forwards one message can collect, instead of adding up every enabled rule, and warns when the rule order lets one message go over it (#1155, thanks @dealerweb)
- **Filters**: A condition with several values wraps inside its card in the expanded rule view (#1139, thanks @dealerweb)
- **Filters**: An open rule dialog or Sieve editor, and what was typed in it, survives a filter change from another tab or device (#1162, thanks @dealerweb)
- **Templates**: A tab or device that had not reloaded no longer deletes templates created elsewhere when it saves its settings
- **Calendar**: Duplicating an event keeps its meeting link (#1170)
- **Calendar**: The Calendar Agenda plugin shows events from shared and group calendars (#1175, thanks @cybersmurf)
- **Files**: Folders shared with you can be opened, and your own folders shared, in both folder layouts (#1181)
- **Files**: Office editing works behind a reverse proxy where the editor and the browser reach the webmail at different addresses. The WOPI client URL may be an internal address (#1130)
- **Login**: When Stalwart accepts a password and TOTP code but refuses to issue tokens, the login page points at the OAuth client settings instead of opening the browser's own sign-in dialog and reporting a wrong code
- **Branding**: With `APP_NAME` set, the authenticator app entry for two-factor sign-in, the About card and the Files preview use the configured name and logo (#1177, thanks @cybersmurf)
- **UI**: Text fields in dialogs keep the focus when the page behind updates, which happened every 30 seconds and on new mail (#1162, thanks @dealerweb)
- **UI**: Buttons, menu items, tabs and other controls show the pointer cursor again (#1184, thanks @gwku)
- **UI**: The account group labels in the From dropdown are readable in dark themes (#1157, thanks @dealerweb)
- **Translations**: Czech is fully translated (#1174, thanks @cybersmurf), and missing placeholders are restored in Persian, Hebrew, Hungarian, Mongolian and Slovak (#1165, thanks @owgreen-dev)
## 1.12.0 (2026-09-30)
1.12.0 lets you create filter rules straight from a message, offers the code from sign-in mail for one-click copying, and gives a phone linked with "Link Mobile App" a sign-in of its own, for every kind of account. Search now leaves Spam and Trash out by default, and emptying an ordinary folder moves its mail to the Trash. Browsers with many logins start up much faster. It also contains three security fixes, so please update.
Thank you for your donations:
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
**One-time**
- Anonymous
**Monthly**
- [@yerTools](https://github.com/yerTools)
- [@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)
- [@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
- **Auth**: The password+TOTP login no longer sends the webmail's OAuth client secret to a mail server the user chose when custom JMAP endpoints are allowed. Only admin-configured servers get it
- **Auth**: The identity provider round trip for "Link Mobile App" can no longer sign the browser in as whichever account answered it, and a re-authentication the provider answered from an existing session instead of a fresh login is refused
- **Mail**: Opening a previewed SVG attachment in a new tab no longer runs the sender's script in the webmail origin
### Features
- **Mail**: A "Rules" entry in the message right-click menu creates filter rules from the message: always move mail from its sender, domain or mailing list, mark it read, tag it, or block the sender. A new rule can also run on the messages already in the folder, and it can be undone from the toast
- **Filters**: The rule editor suggests conditions from the message it was opened from (subject, recipients, List-Id, sender domain), and the new "is the address" and "has the domain" conditions match an address exactly
- **Mail**: Codes from sign-in and confirmation mail show as a copy chip in the message, and in the list for a day. "Show Verification Codes" in settings turns it off
- **Mail**: In the unified view, rows can be tinted with their account's color instead of showing the account dot, and tags this browser has no definition for, such as ones set by a server-side filter, get a color of their own instead of grey (#1052, thanks @lucamzanon)
- **Search**: The folder dropdown shows subfolders indented under their parents
- **Composer**: Pasted plain-text lines that start with "- ", "* ", "• ", "1. " or "1) " become real lists
- **Calendar**: The event popover finds the join link of Teams, Zoom, Meet, Webex, Jitsi, Whereby and GoTo meetings in the location or description, and the location opens in maps, with a copy button next to it (#1095, thanks @lucletoffe)
- **Server**: `LIBRETRANSLATE_API_KEY_FILE`, `BULWARK_JWT_AUTH_SECRET_FILE`, `BULWARK_STALWART_MASTER_USER_FILE` and `BULWARK_STALWART_MASTER_PASSWORD_FILE` read these secrets from files, and the admin config accepts `oauthClientSecretFile` and `sessionSecretFile` (#897, thanks @Cameo007)
- **Plugins**: `PLUGIN_SIGNING_KEY_FILE` points at an existing Ed25519 key for signing managed plugin bundles, for read-only secret mounts (#996, thanks @mulatta)
- **Releases**: Container images and release files come with a signed build provenance attestation, and the standalone tarballs get a `.sha256`. The README explains how to check them with `gh attestation verify`
### Changes
- **Search**: Search leaves out Spam and Trash unless "All folders" is picked in the folder dropdown. A search started in Spam or Trash searches that folder
- **Mail**: Emptying a folder other than Trash or Junk moves its mail to the Trash, unless deleted mail is set to be removed permanently
- **Mobile app**: "Link Mobile App" is offered for every account. Password and password+TOTP accounts confirm their password (and code) in the dialog. The phone gets a sign-in of its own, so signing out in the browser no longer signs it out, and a sign-in link to copy sits next to the QR code. Failed password attempts are limited per account
- **Calendar**: Events are drawn as solid blocks of their calendar's color; declined and cancelled events are outlined
- **UI**: Success and info toasts are plain cards; only errors and warnings are colored
- **Performance**: With many logins, the browser restores them four at a time and no longer reloads every login's folders, identities and push connections each time one of them connects (#1132, thanks @lucamzanon)
- **License**: An additional permission under section 7 of the AGPL allows distributing apps built from this code through app stores. It is provisional until every earlier contributor has agreed ([consent request](https://github.com/orgs/bulwarkmail/discussions/1113)) and applies in full to contributions made since 30 September 2026
- **Docs**: Guidelines for AI-assisted contributions in the README and CONTRIBUTING.md
### Fixes
- **Mail**: Clicking a folder's unread count lists only that folder's unread mail, not unread mail from every folder of every account
- **Mail**: In "unread first" lists, a message you open stays in place until you open another
- **Mail**: The message list no longer flashes a reload right after the page opens
- **Mail**: On tablets, the message list comes back when the reading pane empties, for example after the open message is deleted or moved
- **Mail**: Dividers between rows no longer disappear at 125% or 150% display scaling
- **Mail**: The unread dot is centred on the sender avatar
- **Mail**: Attachments declared with a type the browser cannot show, such as `application/x-as400attachment` from IBM i systems, `application/x-pdf` or a catch-all type, preview according to their file name (#1127, thanks @dealerweb)
- **Send**: Recipients the server refuses at send are reported. When it refuses all of them, the message stays open as not sent; otherwise a warning names the refused ones (#1123)
- **Composer**: The message body stays reachable in short reading panes, such as the bottom reading pane on a laptop (#1114)
- **Composer**: Pasted plain text keeps its blank lines
- **Push**: A notification link waits for a login that is still reconnecting instead of reporting the account as signed out, opens the folder of the right account, and no longer pulls you away from something you opened in the meantime (#1131, thanks @lucamzanon)
- **Push**: A browser subscribed with another VAPID key resubscribes on its own, and Firefox can turn push on again when it cannot read its old subscription (#1121, thanks @shukiv)
- **Calendar**: Dragged events no longer disappear: all-day events moved in the month view keep their start, and occurrences of recurring events with a UTC start, as in Google Calendar exports, no longer shift by the time-zone offset (#1119)
- **Calendar**: A failed calendar fetch no longer hides every calendar or leaves only the birthday calendar selected
- **Calendar**: The all-day area of the week view is sized from the visible week, stacks each day's tasks under its all-day events, and collapses to three rows with a toggle (#1122, thanks @sanitz)
- **Calendar**: The Save button shows while an event is saving, and a save refused for its length says so (#921, thanks @texchi2)
- **Calendar**: Invitations no longer carry blank participant names or an empty description (#748, thanks @lucletoffe)
- **Files**: Files shared with you open and save in the office editor (#1094)
- **Files**: Office editing works behind a reverse proxy at a sub-path (`NEXT_PUBLIC_BASE_PATH`) (#1101, thanks @dealerweb)
- **Accounts**: Adding a sixth account through server-side SSO no longer overwrites the first account's sign-in
- **Mobile app**: The app's "Sign in via webmail" keeps working past the first token expiry when the webmail uses a confidential OAuth client or an identity provider on another host
- **Admin**: Admin sign-in works over plain HTTP. The admin cookie's Secure flag follows the request, or `COOKIE_SECURE` when it is set (#1063, thanks @lucamzanon)
- **Admin**: The OAuth scope fields show the actual default scopes, which do not include `offline_access` (#1126)
- **Lite**: SSO sessions survive a page reload with identity providers such as Rauthy that refuse an early token refresh (#552)
- **UI**: The app retries loading its configuration after a timeout or a failed request instead of staying broken (#1112, thanks @theLucius7)
- **Docker**: The image builds from a remote Git context (#1118)
## 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.
+15 -1
View File
@@ -4,6 +4,18 @@ Bug reports, feature requests, translations and patches are all welcome.
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.
## What fits
Read [What fits in Bulwark](https://bulwarkmail.org/docs/development/contributing#what-fits-in-bulwark) before you start on a feature. In short:
- Bulwark is built for Stalwart. Changes for other JMAP servers are welcome if they cost Stalwart users nothing.
- Core is for what most people expect from a mail, calendar, contacts and files client. Niche workflows and integrations with other services belong in plugins.
- Not in core: IMAP or other non-JMAP backends, integrations with other services, Stalwart server administration, and changes to the look that go beyond themes.
- New behaviour gets one good default. If deployments really need different behaviour, add an admin setting, not a per-user toggle.
- Features must work in Bulwark Lite, or be hidden there, and UI changes must work at phone width.
Open an issue and agree on the approach before you write a change that is larger than about 500 lines (not counting locales and tests), adds a runtime dependency, or touches sign-in, sessions, the HTML sanitizer, the plugin sandbox, outgoing-request guards or admin routes. Deliver large features as a series of pull requests that can each be reviewed on their own. Pull requests with review feedback unanswered for 30 days are closed.
To get a development server running:
```bash
@@ -16,7 +28,9 @@ npm run dev
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).
By contributing, you agree that your contributions are licensed under the terms in [LICENSE](LICENSE): AGPL-3.0-only, with the additional permission for app store distribution.
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.
By contributing, you agree that your contributions are licensed under the terms in [LICENSE](LICENSE): AGPL-3.0-only, with the additional permission for app store distribution. The permission is still provisional for older code, but it applies in full to contributions made since 30 September 2026.
- 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).
+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
+22 -1
View File
@@ -3,7 +3,8 @@ Copyright (C) 2025-2026 Bulwark Project Authors
This project is licensed under the GNU Affero General Public License,
version 3 only, with the additional permission for app store
distribution below.
distribution below. That permission is provisional; see the
reservation at its end.
This repository includes material derived from earlier MIT-licensed work.
The original attribution notice preserved for the fork lineage is available
@@ -41,6 +42,26 @@ holders license under this License; it does not extend to parts licensed
under other terms. As section 7 of this License allows, you may remove this
permission from copies you convey and from your modified versions.
Reservation
This additional permission has not been finalized. The contributors of
earlier parts of this Program have been asked whether they agree to it:
https://github.com/orgs/bulwarkmail/discussions/1113
Until that request is settled, the permission is granted subject to this
reservation:
- It applies to all contributions made on or after 30 September 2026.
These contributions are accepted under this License together with
this permission.
- It applies to parts of this Program written before that date only
where their copyright holders have agreed to it. Parts whose
copyright holders do not agree will be rewritten or removed.
This reservation will be removed once every earlier contributor has
agreed or their parts have been replaced.
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
+7
View File
@@ -32,3 +32,10 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Third-party artwork:
- Flag icons in components/ui/flag-icons.tsx are from Twemoji
(https://github.com/jdecked/twemoji), Copyright 2019 Twitter, Inc and
other contributors, licensed under CC-BY 4.0
(https://creativecommons.org/licenses/by/4.0/).
+13 -2
View File
@@ -6,7 +6,7 @@
<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 online" height="24" /></picture></a>
<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>
<p align="center">
@@ -32,6 +32,7 @@ Bulwark Webmail is a self-hosted webmail client for [Stalwart Mail Server](https
- [Configuration](#configuration)
- [Documentation](#documentation)
- [Development](#development)
- [Use of AI](#use-of-ai)
- [Community and support](#community-and-support)
- [License](#license)
@@ -83,6 +84,8 @@ Then open `http://localhost:3000`. A setup wizard asks for your Stalwart server
[Updating](https://bulwarkmail.org/docs/deployment/updating) explains how to move to a new version.
Container images and release files from 1.12.0 on 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.
@@ -135,6 +138,14 @@ The [contributing guide](https://bulwarkmail.org/docs/development/contributing)
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/).
## Use of AI
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.
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.
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.
## Community and support
- Questions: check [Troubleshooting](https://bulwarkmail.org/docs/guides/troubleshooting), then ask on [Discord](https://discord.gg/tYCujymGrT).
@@ -144,7 +155,7 @@ The stack: [Next.js 16](https://nextjs.org/) and React 19, TypeScript, [Tailwind
## License
[GNU AGPL v3 only](LICENSE), with an additional permission to distribute apps built from this code through app stores such as the Apple App Store and Google Play. This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
[GNU AGPL v3 only](LICENSE), with an additional permission to distribute apps built from this code through app stores such as the Apple App Store and Google Play. The permission is provisional until every earlier contributor has agreed to it ([consent request](https://github.com/orgs/bulwarkmail/discussions/1113)); contributions made since 30 September 2026 are already covered. This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
## Acknowledgments
+1 -1
View File
@@ -1 +1 @@
1.11.0
1.14.1
+12 -2
View File
@@ -1,6 +1,6 @@
"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";
@@ -18,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");
@@ -38,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 */ }
+2
View File
@@ -9,6 +9,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { QuickRuleHost } from "@/components/filters/quick-rule-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
@@ -55,6 +56,7 @@ export default async function LocaleLayout({
<ImpersonationReconciler />
{children}
<PluginDialogHost />
<QuickRuleHost />
<PluginConsentDialog />
<PluginOAuthCallbackListener />
<PWAInstallPrompt />
+151 -42
View File
@@ -14,11 +14,12 @@ 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 "@/components/icons";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy, KeyRound } from "@/components/icons";
import { type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { DEFAULT_OAUTH_SCOPES } from "@/lib/oauth/scopes";
import { useUpdateStore, selectBanner } from "@/stores/update-store";
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
import { offersOwnOAuth, type PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
import { IS_LITE, getLiteInjectedClientId } from "@/lib/lite";
import { getLiteClientId, probeLiteTokenLogin } from "@/lib/auth/lite-tokens";
import {
@@ -154,9 +155,9 @@ function LoginPageContent() {
: "";
const mobileState = mobileRedirectUri ? rawMobileState : "";
const isMobileHandoff = Boolean(mobileRedirectUri);
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { login, loginWithToken, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowTokenLogin, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
// Login logo sizing: when a max height/width is configured, drop the fixed
@@ -167,7 +168,8 @@ function LoginPageContent() {
: undefined;
const [formData, setFormData] = useState({
username: "",
// An account restored from the list backup opens this form with its address.
username: isAddAccountMode ? searchParams.get("username") ?? "" : "",
password: "",
});
const [jmapEndpoint, setJmapEndpoint] = useState("");
@@ -187,8 +189,17 @@ function LoginPageContent() {
const effectiveOauthIssuerUrl = selectedServer
? selectedServer.oauth?.issuerUrl || selectedServer.url
: globalOauthIssuerUrl;
// A server entry that names its own OAuth client and button label signs in
// with OAuth even while OAuth is off globally: a Gmail bridge next to a
// password server.
const serverOauthEnabled = oauthEnabled || offersOwnOAuth(selectedServer);
const [totpCode, setTotpCode] = useState("");
const [showTotpField, setShowTotpField] = useState(false);
// Access-token sign-in (Bearer auth) in place of username and password.
// The mobile hand-off passes a password on to the app, so it has none.
const [tokenMode, setTokenMode] = useState(false);
const [accessToken, setAccessToken] = useState("");
const signInWithToken = tokenMode && loginShowTokenLogin && !isMobileHandoff;
const [rememberMe, setRememberMe] = useState(false);
// Lite (no server session): "remember me" needs Stalwart's token login on the
// target server. Probe it once per URL and hide the box when it is missing;
@@ -212,6 +223,7 @@ function LoginPageContent() {
const inputRef = useRef<HTMLInputElement>(null);
const justSelectedSuggestion = useRef(false);
const totpInputRef = useRef<HTMLInputElement>(null);
const tokenInputRef = useRef<HTMLInputElement>(null);
const prevError = useRef<string | null>(null);
const themeMenuRef = useRef<HTMLDivElement>(null);
const themeButtonRef = useRef<HTMLButtonElement>(null);
@@ -256,7 +268,7 @@ function LoginPageContent() {
// (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 liteDomain = LITE_OAUTH_AVAILABLE && !signInWithToken ? completeAddressDomain(formData.username) : "";
const liteAccount = liteDomain ? formData.username.trim() : "";
useEffect(() => {
if (!liteAccount || !probeTarget) return;
@@ -417,7 +429,7 @@ function LoginPageContent() {
}, [serverUrl]);
useEffect(() => {
if (!oauthEnabled || !serverUrl) return;
if (!serverOauthEnabled || !serverUrl) return;
setOauthDiscoveryDone(false);
setOauthMetadata(null);
const controller = new AbortController();
@@ -440,7 +452,7 @@ function LoginPageContent() {
setOauthDiscoveryDone(true);
});
return () => controller.abort();
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl, selectedServer?.id]);
}, [serverOauthEnabled, serverUrl, effectiveOauthIssuerUrl, selectedServer?.id]);
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
const ssoError = searchParams.get("sso_error");
@@ -685,6 +697,7 @@ function LoginPageContent() {
clientId,
redirectUri,
persistent: rememberMeEnabled && rememberMe,
revocationEndpoint: discovery.metadata.revocation_endpoint,
});
const authUrl = new URL(discovery.metadata.authorization_endpoint);
@@ -703,12 +716,36 @@ function LoginPageContent() {
window.location.href = authUrl.toString();
};
const handleOAuthLogin = async () => {
if (LITE_OAUTH_AVAILABLE) {
/**
* Starts OAuth for the selected server, or for `target`: a server in the
* list with its own OAuth client (a Gmail bridge, say), offered on the page
* whichever server is selected so that "Sign in with Google" is one tap.
*/
const handleOAuthLogin = async (target?: PublicJmapServerEntry) => {
if (LITE_OAUTH_AVAILABLE && !target) {
await startLiteOAuth(probeTarget);
return;
}
if (!oauthMetadata || !effectiveOauthClientId) return;
const server = target ?? selectedServer;
const clientId = target ? target.oauth?.clientId : effectiveOauthClientId;
let metadata = target ? null : oauthMetadata;
if (target) {
setOauthLoading(true);
setSelectedServerId(target.id);
try {
const res = await apiFetch(`/api/auth/oauth/metadata?server_id=${encodeURIComponent(target.id)}`);
metadata = res.ok ? ((await res.json()) as OAuthMetadata) : null;
} catch {
metadata = null;
}
if (!metadata) {
// The target is now the selected server: its discovery runs again and
// the page shows the usual "discovery failed" notice.
setOauthLoading(false);
return;
}
}
if (!metadata || !clientId) 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
// mobile app couldn't read them. Route through the server-side SSO
@@ -728,14 +765,14 @@ function LoginPageContent() {
// Resolve the JMAP URL to send to the callback. Server-list entries win
// over the custom-endpoint input, which wins over the global server URL.
const oauthServerUrl = selectedServer?.url
const oauthServerUrl = server?.url
|| (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl);
sessionStorage.setItem("oauth_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
sessionStorage.setItem("oauth_server_url", oauthServerUrl!);
if (selectedServer?.id) {
sessionStorage.setItem("oauth_server_id", selectedServer.id);
if (server?.id) {
sessionStorage.setItem("oauth_server_id", server.id);
} else {
sessionStorage.removeItem("oauth_server_id");
}
@@ -753,11 +790,11 @@ function LoginPageContent() {
const nextSlot = useAccountStore.getState().getNextCookieSlot();
sessionStorage.setItem("oauth_cookie_slot", nextSlot.toString());
const authUrl = new URL(oauthMetadata.authorization_endpoint);
const authUrl = new URL(metadata.authorization_endpoint);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", effectiveOauthClientId);
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("scope", oauthScopes || "openid email profile");
authUrl.searchParams.set("scope", oauthScopes || DEFAULT_OAUTH_SCOPES);
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
@@ -775,6 +812,12 @@ function LoginPageContent() {
// when the admin hasn't configured a server list.
const effectiveServerUrl = selectedServer?.url
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
if (signInWithToken) {
if (await loginWithToken(effectiveServerUrl, accessToken, rememberMeEnabled && rememberMe)) {
router.push('/');
}
return;
}
// 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
@@ -808,7 +851,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.
@@ -880,6 +925,27 @@ function LoginPageContent() {
</div>
) : null;
// Other servers in the list that sign in with their own OAuth client get a
// button of their own, so signing in with Google does not first mean finding
// the bridge in the server menu. Not in mobile handoff, which signs in
// server-side against the selected server.
const otherOauthServers = isMobileHandoff
? []
: jmapServers.filter((s) => offersOwnOAuth(s) && s.id !== selectedServer?.id);
const otherOauthButtons = otherOauthServers.map((s) => (
<Button
key={s.id}
type="button"
variant="outline"
className="w-full h-11 font-medium text-[15px] rounded-xl border-border/60 hover:bg-muted/50"
onClick={() => handleOAuthLogin(s)}
disabled={oauthLoading || isLoading}
>
<LogIn className="w-4 h-4 me-2" />
{s.oauth?.buttonLabel}
</Button>
));
// Demo-only mode: show only a large demo login button
if (demoMode && !isAddAccountMode) {
return (
@@ -1196,7 +1262,7 @@ function LoginPageContent() {
<Button
type="button"
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"
onClick={handleOAuthLogin}
onClick={() => handleOAuthLogin()}
disabled={oauthLoading}
>
{oauthLoading ? (
@@ -1207,7 +1273,7 @@ function LoginPageContent() {
) : (
<div className="flex items-center gap-2">
<LogIn className="w-4 h-4" />
{t("sign_in_sso")}
{selectedServer?.oauth?.buttonLabel || t("sign_in_sso")}
</div>
)}
</Button>
@@ -1254,8 +1320,9 @@ function LoginPageContent() {
</div>
)}
{/* Username field */}
<div className="space-y-1.5">
{/* Username field. The access token names the account, so
token sign-in has none. */}
<div className={cn("space-y-1.5", signInWithToken && "hidden")}>
<label htmlFor="username" className="block text-sm font-medium text-foreground">
{t("username_label")}
</label>
@@ -1270,7 +1337,7 @@ function LoginPageContent() {
onKeyDown={handleKeyDown}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder={t("username_placeholder")}
required
required={!signInWithToken}
autoComplete="off"
data-form-type="other"
data-lpignore="true"
@@ -1311,7 +1378,7 @@ function LoginPageContent() {
{/* 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")}>
<div className={cn("space-y-1.5", (liteSsoRequired || signInWithToken) && "hidden")}>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
{t("password_label")}
</label>
@@ -1323,7 +1390,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={!liteSsoRequired}
required={!liteSsoRequired && !signInWithToken}
autoComplete="current-password"
/>
<button
@@ -1342,11 +1409,35 @@ function LoginPageContent() {
</div>
</div>
{signInWithToken && (
<div className="space-y-1.5">
<label htmlFor="access-token" className="block text-sm font-medium text-foreground">
{t("token_label")}
</label>
<Input
ref={tokenInputRef}
id="access-token"
type="password"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 font-mono"
placeholder={t("token_placeholder")}
required
autoComplete="off"
spellCheck={false}
data-lpignore="true"
/>
<p className="text-[11px] text-muted-foreground leading-snug">
{t("token_hint")}
</p>
</div>
)}
{/* 2FA toggle / field. The manual toggle can be hidden via
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. */}
{liteSsoRequired ? null : !showTotpField ? (
{liteSsoRequired || signInWithToken ? null : !showTotpField ? (
loginShowTotp ? (
<button
type="button"
@@ -1384,6 +1475,21 @@ function LoginPageContent() {
</div>
)}
{loginShowTokenLogin && !isMobileHandoff && !liteSsoRequired && (
<button
type="button"
onClick={() => {
clearError();
setTokenMode(!signInWithToken);
setTimeout(() => (signInWithToken ? inputRef : tokenInputRef).current?.focus(), 50);
}}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<KeyRound className="w-3.5 h-3.5" />
{signInWithToken ? t("password_toggle") : t("token_toggle")}
</button>
)}
{/* Remember me */}
{showRememberMe && (
<label className="flex items-center gap-2.5 cursor-pointer group select-none pt-1">
@@ -1427,7 +1533,7 @@ function LoginPageContent() {
)}
</Button>
{(oauthMetadata || liteSsoOffered) && (
{(oauthMetadata || liteSsoOffered || otherOauthButtons.length > 0) && (
<>
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
@@ -1438,24 +1544,27 @@ function LoginPageContent() {
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full h-11 font-medium text-[15px] rounded-xl border-border/60 hover:bg-muted/50"
onClick={handleOAuthLogin}
disabled={oauthLoading || isLoading}
>
{oauthLoading ? (
<Loader2 className="w-4 h-4 animate-spin me-2" />
) : (
<LogIn className="w-4 h-4 me-2" />
)}
{t("sign_in_sso")}
</Button>
{(oauthMetadata || liteSsoOffered) && (
<Button
type="button"
variant="outline"
className="w-full h-11 font-medium text-[15px] rounded-xl border-border/60 hover:bg-muted/50"
onClick={() => handleOAuthLogin()}
disabled={oauthLoading || isLoading}
>
{oauthLoading ? (
<Loader2 className="w-4 h-4 animate-spin me-2" />
) : (
<LogIn className="w-4 h-4 me-2" />
)}
{selectedServer?.oauth?.buttonLabel || t("sign_in_sso")}
</Button>
)}
{otherOauthButtons}
</>
)}
{((oauthEnabled && oauthDiscoveryDone && !oauthMetadata) || liteOAuthFailed) && (
{((serverOauthEnabled && 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" />
@@ -19,6 +19,7 @@ interface RowDraft {
oauthClientId: string;
oauthIssuerUrl: string;
oauthClientSecret: string;
oauthButtonLabel: string;
oauthExpanded: boolean;
}
@@ -31,6 +32,7 @@ function entryToDraft(e: JmapServerEntry): RowDraft {
oauthClientId: e.oauth?.clientId ?? '',
oauthIssuerUrl: e.oauth?.issuerUrl ?? '',
oauthClientSecret: e.oauth?.clientSecret ?? '',
oauthButtonLabel: e.oauth?.buttonLabel ?? '',
oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret)),
};
}
@@ -46,11 +48,13 @@ function draftToEntry(d: RowDraft): JmapServerEntry | null {
const clientId = d.oauthClientId.trim();
const issuerUrl = d.oauthIssuerUrl.trim().replace(/\/+$/, '');
const clientSecret = d.oauthClientSecret;
const buttonLabel = d.oauthButtonLabel.trim();
const oauth = clientId || issuerUrl || clientSecret
? {
...(clientId ? { clientId } : {}),
...(issuerUrl ? { issuerUrl } : {}),
...(clientSecret ? { clientSecret } : {}),
...(buttonLabel ? { buttonLabel } : {}),
}
: undefined;
return {
@@ -71,6 +75,7 @@ function emptyDraft(): RowDraft {
oauthClientId: '',
oauthIssuerUrl: '',
oauthClientSecret: '',
oauthButtonLabel: '',
oauthExpanded: false,
};
}
@@ -249,6 +254,20 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
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"
/>
</div>
<div className="sm:col-span-3">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Sign-in button label</label>
<input
type="text"
value={d.oauthButtonLabel}
onChange={(e) => update(i, { oauthButtonLabel: e.target.value })}
placeholder="Sign in with Google"
maxLength={64}
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"
/>
<p className="mt-1 text-[11px] text-muted-foreground">
With a client ID, this label gives the server its own sign-in button on the login page, whichever server is selected and even while OAuth is off globally.
</p>
</div>
</div>
)}
</div>
+3 -2
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { Save, Loader2, RotateCcw, Sparkles } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { DEFAULT_OAUTH_SCOPES } from '@/lib/oauth/scopes';
interface ConfigEntry {
// Sensitive keys (sessionSecret, oauthClientSecret) come back with
@@ -274,8 +275,8 @@ export function AuthTab() {
<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 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:..." />
<Text label="OAuth Scopes" description={`Space-separated scopes that replace the defaults (${DEFAULT_OAUTH_SCOPES}). Leave blank to use them. offline_access is not requested by default; add it here or under Extra Scopes if your provider only issues refresh tokens for it, or sessions end when the access token expires.`} configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder={DEFAULT_OAUTH_SCOPES} />
<Text label="OAuth Extra Scopes" description={`Additional space-separated scopes appended to the defaults (${DEFAULT_OAUTH_SCOPES}). Ignored when OAuth Scopes is set.`} 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">
+3
View File
@@ -32,16 +32,19 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
tabTitleSubjectEnabled: { label: 'Subject in Tab Title', description: 'Show the open message\'s subject in the browser tab title. Browsers also keep the title in history and screen shares show it; turn this off when mailboxes hold confidential correspondence, and the tab shows the folder instead.' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
accountArchiveEnabled: { label: 'Account List Backup', description: 'Let users save their list of accounts (address, server, label, colour) on this server, encrypted in the browser with a password of their choice, and restore it on another device. No mailbox passwords are stored. Archives live in SETTINGS_DATA_DIR/account-archives; requires SESSION_SECRET.' },
};
const RESTRICTABLE_SETTINGS = [
{ key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
{ key: 'interfaceLayout', label: 'Interface Layout', category: 'Appearance', type: 'enum', allowedValues: ['default', 'mountain-view'] },
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
+3 -2
View File
@@ -131,6 +131,7 @@ export function SettingsTab() {
<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 Access Token Sign-In" description="Offer signing in with an access token instead of a password. For JMAP servers that authenticate API tokens as Bearer tokens, such as Fastmail, which accepts no passwords over JMAP." configKey="loginShowTokenLogin" value={currentValue('loginShowTokenLogin') as boolean} source={config.loginShowTokenLogin?.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>
@@ -162,10 +163,10 @@ export function SettingsTab() {
<SettingsSection title="Document Editing (WOPI)">
<TextSetting label="WOPI Client URL" configKey="wopiClientUrl" value={currentValue('wopiClientUrl') as string} source={config.wopiClientUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://office.example.com" />
<TextSetting label="WOPI Host URL Override" configKey="wopiHostUrl" value={currentValue('wopiHostUrl') as string} source={config.wopiHostUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
<TextSetting label="WOPI Host URL Override" configKey="wopiHostUrl" value={currentValue('wopiHostUrl') as string} source={config.wopiHostUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="http://webmail:3000" />
<div className="px-4 py-2.5">
<p className="text-xs text-muted-foreground leading-relaxed">
Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks). The editor must also allow this webmail as a WOPI host.
Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The client URL may be an internal address: the browser loads the editor from the address its discovery advertises. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks); give the address alone, the webmail&apos;s base path is added to it. The editor must also allow this webmail as a WOPI host.
</p>
</div>
</SettingsSection>
+6 -3
View File
@@ -1,9 +1,10 @@
'use client';
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
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));
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { configManager } from '@/lib/admin/config-manager';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { assertBasicAuthMatchesUsername, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { archiveIdentity, parseArchiveEnvelope, ARCHIVE_MAX_BYTES, type ArchiveOwner } from '@/lib/account-archive';
import { deleteArchive, loadArchive, saveArchive } from '@/lib/account-archive-storage';
export const runtime = 'nodejs';
const reply = (data: unknown, status = 200) => NextResponse.json(data, {
status, headers: { 'Cache-Control': 'no-store' },
});
/**
* The owner whose archive this request may touch, or the response refusing it.
*
* A session cookie alone is not enough: cookies for admin-configured servers
* can outlive the credential inside them. So the credential in the matching
* slot is checked against the mail server on every call, and listing is held
* to the same rule as writing, since the ciphertext is what an offline
* password guess would need.
*/
async function verifiedOwner(request: NextRequest): Promise<ArchiveOwner | NextResponse> {
await configManager.ensureLoaded();
if (!configManager.getPolicy().features.accountArchiveEnabled || !hasSessionSecret()) return reply({ error: 'disabled' }, 404);
const denied = reply({ error: 'owner_signin_required' }, 403);
let claimed: string;
try {
claimed = archiveIdentity({
username: request.headers.get('x-archive-username') ?? '',
serverUrl: request.headers.get('x-archive-server') ?? '',
});
} catch { return denied; }
const store = await cookies();
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
const ctx = readStalwartAuthContextFromStore(store, slot);
if (!ctx) continue;
try { if (archiveIdentity(ctx) !== claimed) continue; } catch { continue; }
try {
// The cookie's own username, not the header's: the two may differ in case.
assertBasicAuthMatchesUsername(ctx.authHeader, ctx.username);
const trusted = resolveTrustedJmapUrl(ctx.serverUrl,
configManager.get<string>('jmapServerUrl', ''),
parseJmapServers(configManager.get<unknown>('jmapServers', [])));
await verifyJmapAuth(ctx.serverUrl, ctx.authHeader, { trusted: !!trusted });
} catch { return denied; }
return { username: ctx.accountName ?? ctx.username, serverUrl: ctx.serverUrl };
}
return denied;
}
export async function GET(request: NextRequest) {
const owner = await verifiedOwner(request);
if (owner instanceof NextResponse) return owner;
try { return reply({ envelope: await loadArchive(owner) }); }
catch { return reply({ error: 'storage_failed' }, 500); }
}
export async function PUT(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
const owner = await verifiedOwner(request);
if (owner instanceof NextResponse) return owner;
if (Number(request.headers.get('content-length') ?? 0) > ARCHIVE_MAX_BYTES) return reply({ error: 'too_large' }, 413);
let envelope;
try {
const text = await request.text();
if (text.length > ARCHIVE_MAX_BYTES) return reply({ error: 'too_large' }, 413);
envelope = parseArchiveEnvelope((JSON.parse(text) as { envelope?: unknown })?.envelope);
} catch { return reply({ error: 'invalid_archive' }, 400); }
try { await saveArchive(owner, envelope); return reply({ ok: true }); }
catch { return reply({ error: 'storage_failed' }, 500); }
}
export async function DELETE(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
const owner = await verifiedOwner(request);
if (owner instanceof NextResponse) return owner;
try { await deleteArchive(owner); return reply({ ok: true }); }
catch { return reply({ error: 'storage_failed' }, 500); }
}
+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);
+60 -9
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
import { encryptSession, decryptSession, sessionAuthHeader, type SessionCredentials } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { JmapAuthVerificationError, verifyJmapIdentity } from '@/lib/auth/verify-jmap-auth';
@@ -16,6 +16,11 @@ import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-server
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 {
@@ -24,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: SessionCredentials): 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;
@@ -46,8 +85,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
}
const { serverUrl, username, password, slot: bodySlot } = await request.json();
if (!serverUrl || !username || !password) {
// A password signs in with Basic auth, an access token (token login) with
// Bearer: exactly one of them.
const { serverUrl, username, password, token: accessToken, slot: bodySlot } = await request.json();
if (!serverUrl || !username || typeof (password || accessToken) !== 'string' || !!password === !!accessToken) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
@@ -81,7 +122,9 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const authHeader = accessToken
? `Bearer ${accessToken}`
: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
// Always verify the credential upstream (GHSA-wxcm-j4jc-9fxq). The cookies
// written here are not only replayed as credentials (where a bogus
// password would just 401 downstream): the encrypted auth context is also
@@ -92,7 +135,7 @@ export async function POST(request: NextRequest) {
const normalizedServerUrl = await verifyJmapIdentity(upstreamUrl, authHeader, username, {
trusted: upstreamTrusted,
});
const token = encryptSession(normalizedServerUrl, username, password);
const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password);
const cookieStore = await cookies();
cookieStore.set(cookieName, token, sessionCookieOptions());
setStalwartAuthContextInStore(cookieStore, slot, {
@@ -140,7 +183,7 @@ export async function GET(request: NextRequest) {
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
authHeader: sessionAuthHeader(credentials),
});
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
@@ -156,7 +199,7 @@ export async function GET(request: NextRequest) {
}
/**
* PUT - retrieve full credentials (including password) for session restoration.
* PUT - retrieve full credentials (password or access token) for session restoration.
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
* browser fetch() requests succeed. Non-browser clients cannot forge these.
*/
@@ -181,7 +224,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 });
@@ -190,7 +233,7 @@ export async function PUT(request: NextRequest) {
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
authHeader: sessionAuthHeader(credentials),
});
return NextResponse.json(credentials, {
@@ -210,6 +253,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.
@@ -217,10 +262,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 });
+53 -10
View File
@@ -6,15 +6,32 @@ 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';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
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.
@@ -32,8 +49,10 @@ export async function POST(request: NextRequest) {
// Per-account refresh-token cookie slot. Without this the route hardcoded
// slot 0, so the "+ Add Account" flow overwrote the first account's
// refresh-token cookie. Default to 0 for back-compat with any caller that
// omits slot. Mirrors the validation in /api/auth/token POST.
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
// omits slot. Mirrors the validation in /api/auth/token POST: slots run
// up to MAX_ACCOUNT_SLOTS, so a sixth account must not land on slot 0.
const slot = typeof bodySlot === 'number' && Number.isInteger(bodySlot)
&& bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
// Read and decrypt the pending SSO cookie
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
@@ -63,6 +82,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;
@@ -105,16 +132,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,
});
+19 -5
View File
@@ -21,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.
@@ -66,11 +79,11 @@ export async function POST(request: NextRequest) {
// 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) {
const slot =
typeof rawSlot === 'number' && rawSlot >= 0 && rawSlot < MAX_ACCOUNT_SLOTS ? rawSlot : 0;
serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
serverId = cookieStore.get(refreshTokenServerCookieName(reauthSlot))?.value || null;
}
// Validate redirect_uri origin matches the request origin to prevent open redirects
@@ -110,7 +123,8 @@ 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');
@@ -129,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');
+3 -2
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';
@@ -71,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,
});
@@ -79,6 +79,7 @@ export async function POST(request: NextRequest) {
serverUrl: normalizedServerUrl,
username,
authHeader,
...(accountName !== username ? { accountName } : {}),
});
void recordLogin(username, normalizedServerUrl);
+5
View File
@@ -52,11 +52,14 @@ async function revokeRefreshToken(token: string, serverId: string | null, metada
}, 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 });
@@ -205,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) {
+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) {
+18 -4
View File
@@ -5,9 +5,12 @@ 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).
* Server-side credential pre-check for the login form (#969): a password
* (Basic auth), or the access token of a token login (Bearer auth).
*
* When the browser itself probes the JMAP session URL with wrong credentials,
* the server answers 401 + `WWW-Authenticate: Basic`, and on a same-origin
@@ -40,8 +43,10 @@ export async function POST(request: NextRequest) {
const serverUrl = body?.serverUrl;
const username = body?.username;
const password = body?.password;
if (typeof serverUrl !== 'string' || typeof username !== 'string' || typeof password !== 'string'
|| !serverUrl || !username || !password) {
const token = body?.token;
const hasPassword = typeof username === 'string' && typeof password === 'string' && !!username && !!password;
const hasToken = typeof token === 'string' && !!token;
if (typeof serverUrl !== 'string' || !serverUrl || hasPassword === hasToken) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
@@ -75,14 +80,23 @@ export async function POST(request: NextRequest) {
return respond('inconclusive');
}
const authHeader = 'Basic ' + Buffer.from(username + ':' + password).toString('base64');
// 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 = hasToken
? `Bearer ${token}`
: '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) {
+150 -89
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';
@@ -11,10 +12,15 @@ import type { CalendarEvent } from '@/lib/jmap/types';
* POST /api/calendar-agenda
*
* Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar
* account from the stored Stalwart auth context, queries upcoming
* accounts from the stored Stalwart auth context, queries upcoming
* CalendarEvents over JMAP with Stalwart expanding recurring series into
* occurrences, and returns a slim, structured-cloneable agenda the plugin can
* render directly.
* occurrences, and returns a slim, structured-cloneable agenda the plugin
* can render directly.
*
* Events come from every account that has calendars: the primary calendar
* account plus shared and group accounts (a family or team calendar), the
* same set the calendar app shows. Ids are scoped by account, since event
* and calendar ids repeat across accounts.
*
* Credentials never leave the server — the sandboxed plugin only ever sees
* the resulting agenda DTOs.
@@ -41,7 +47,9 @@ const GET_BATCH = 100;
const MAX_CALLS_FALLBACK = 16;
interface AgendaEvent {
/** Unique across accounts: `<accountId>:<event id>`. */
id: string;
accountId: string;
uid: string | null;
title: string;
start: string;
@@ -107,6 +115,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) {
@@ -133,8 +145,8 @@ export async function POST(request: NextRequest) {
if (!session) {
return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 });
}
const accountId = session.primaryAccounts?.[CALENDAR_CAP];
if (!accountId) {
const accountIds = calendarAccountIds(session);
if (accountIds.length === 0) {
// No calendar account for this user — return an empty agenda, not an error.
return NextResponse.json({ events: [], generatedAt: new Date().toISOString() });
}
@@ -155,72 +167,22 @@ export async function POST(request: NextRequest) {
const windowStart = new Date(now);
windowStart.setHours(0, 0, 0, 0);
// ── 1) Query the occurrences in range + load calendars (colours) ──
// ── 1–3) Per account: query the occurrences in range, load calendars
// (colours), fetch the occurrences and hydrate them from their bases ──
// expandRecurrences returns one id per occurrence (it needs both bounds),
// the earliest first so the limit keeps the next ones. A range the server
// will not expand - more occurrences than it allows in one query, or
// longer than its maxExpandedQueryDuration - is refused; the agenda then
// looks less far ahead instead of failing.
let queryRes: unknown;
let queryResp: Record<string, unknown> | null;
for (;;) {
queryRes = await jmapPost(apiUrl, creds.authHeader, {
using,
methodCalls: [
[
'CalendarEvent/query',
{
accountId,
filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
expandRecurrences: true,
sort: [{ property: 'start', isAscending: true }],
limit: 1000,
},
'0',
],
[
'Calendar/get',
{ accountId, ids: null, properties: ['id', 'name', 'color'] },
'c',
],
],
}, fetchOptions);
queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
if (queryResp) break;
const err = findResponse(queryRes, 'error', '0');
const span = horizon.getTime() - now.getTime();
if ((isOccurrenceLimit(err) || err?.type === 'expandDurationTooLarge') && span > DAY_MS) {
horizon = new Date(now.getTime() + Math.max(DAY_MS, Math.floor(span / 2)));
continue;
}
return NextResponse.json(
{ error: (err?.description as string) || 'CalendarEvent/query failed' },
{ status: 502 },
);
}
const ids = (queryResp.ids as string[]) || [];
const calColors = new Map<string, { name: string; color: string | null }>();
const calResp = findResponse(queryRes, 'Calendar/get', 'c');
for (const cal of ((calResp?.list as Array<Record<string, unknown>>) || [])) {
if (typeof cal.id === 'string') {
calColors.set(cal.id, {
name: typeof cal.name === 'string' ? cal.name : '',
color: typeof cal.color === 'string' ? cal.color : null,
});
}
}
const expanded: Array<CalendarEvent & { accountId: string }> = [];
if (ids.length === 0) {
return NextResponse.json({ events: [], generatedAt: now.toISOString() });
}
// ── 2) Fetch the occurrences (batched, several gets per request) ──
const coreCapability = session.capabilities?.['urn:ietf:params:jmap:core'] as { maxCallsInRequest?: unknown } | undefined;
const maxCalls = typeof coreCapability?.maxCallsInRequest === 'number' && coreCapability.maxCallsInRequest > 0
? coreCapability.maxCallsInRequest
: MAX_CALLS_FALLBACK;
const getEvents = async (eventIds: string[], properties: readonly string[]) => {
// Batched, several gets per request.
const getEvents = async (accountId: string, eventIds: string[], properties: readonly string[]) => {
const list: Array<Record<string, unknown>> = [];
const batches: string[][] = [];
for (let i = 0; i < eventIds.length; i += GET_BATCH) batches.push(eventIds.slice(i, i + GET_BATCH));
@@ -242,34 +204,107 @@ export async function POST(request: NextRequest) {
return list;
};
let occurrences: Partial<CalendarEvent>[];
const bases = new Map<string, Partial<CalendarEvent>>();
try {
occurrences = (await getEvents(ids, EVENT_PROPERTIES))
.map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>));
// ── 3) Hydrate occurrences from their base events ──
// An expanded all-day occurrence comes back without showWithoutTime,
// and the base event has the duration an override leaves out.
const baseIds = Array.from(new Set(occurrences
.filter((e) => isServerRecurrenceInstance(e as CalendarEvent) && e.recurrenceId)
.map((e) => e.baseEventId as string)));
for (const base of await getEvents(baseIds, RECURRENCE_BASE_PROPERTIES)) {
if (typeof base.id === 'string') bases.set(base.id, normalizeCalendarEventLike(base as Partial<CalendarEvent>));
for (const accountId of accountIds) {
const isPrimary = accountId === accountIds[0];
let queryRes: unknown;
let queryResp: Record<string, unknown> | null;
let queryErr: Record<string, unknown> | null = null;
for (;;) {
queryRes = await jmapPost(apiUrl, creds.authHeader, {
using,
methodCalls: [
[
'CalendarEvent/query',
{
accountId,
filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
expandRecurrences: true,
sort: [{ property: 'start', isAscending: true }],
limit: 1000,
},
'0',
],
[
'Calendar/get',
{ accountId, ids: null, properties: ['id', 'name', 'color'] },
'c',
],
],
}, fetchOptions);
queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
if (queryResp) break;
queryErr = findResponse(queryRes, 'error', '0');
const span = horizon.getTime() - now.getTime();
if ((isOccurrenceLimit(queryErr) || queryErr?.type === 'expandDurationTooLarge') && span > DAY_MS) {
horizon = new Date(now.getTime() + Math.max(DAY_MS, Math.floor(span / 2)));
continue;
}
break;
}
if (!queryResp) {
if (!isPrimary) {
// A shared account the caller can see but not read calendars from
// (Stalwart answers forbidden/accountNotSupported) must not empty
// the agenda for the accounts that do work.
logger.debug('Calendar agenda: skipping account', {
accountId,
error: (queryErr?.type as string) || 'CalendarEvent/query failed',
});
continue;
}
return NextResponse.json(
{ error: (queryErr?.description as string) || 'CalendarEvent/query failed' },
{ status: 502 },
);
}
const ids = (queryResp.ids as string[]) || [];
const calResp = findResponse(queryRes, 'Calendar/get', 'c');
for (const cal of ((calResp?.list as Array<Record<string, unknown>>) || [])) {
if (typeof cal.id === 'string') {
calColors.set(scopedId(accountId, cal.id), {
name: typeof cal.name === 'string' ? cal.name : '',
color: typeof cal.color === 'string' ? cal.color : null,
});
}
}
if (ids.length === 0) continue;
let occurrences: Partial<CalendarEvent>[];
const bases = new Map<string, Partial<CalendarEvent>>();
try {
occurrences = (await getEvents(accountId, ids, EVENT_PROPERTIES))
.map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>));
// Hydrate occurrences from their base events: an expanded all-day
// occurrence comes back without showWithoutTime, and the base event
// has the duration an override leaves out.
const baseIds = Array.from(new Set(occurrences
.filter((e) => isServerRecurrenceInstance(e as CalendarEvent) && e.recurrenceId)
.map((e) => e.baseEventId as string)));
for (const base of await getEvents(accountId, baseIds, RECURRENCE_BASE_PROPERTIES)) {
if (typeof base.id === 'string') bases.set(base.id, normalizeCalendarEventLike(base as Partial<CalendarEvent>));
}
} catch (error) {
if (!(error instanceof AgendaFetchError)) throw error;
if (!isPrimary) {
logger.debug('Calendar agenda: skipping account', { accountId, error: error.message });
continue;
}
return NextResponse.json({ error: error.message }, { status: 502 });
}
const valid = occurrences
.filter((e) => (e['@type'] ?? 'Event') === 'Event')
// Drop malformed events without a parseable start (would crash format()/
// parseISO downstream) — mirrors the calendar store guard (#316).
.filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[];
for (const e of hydrateRecurrenceInstances(valid, bases)) {
expanded.push({ ...e, accountId });
}
} catch (error) {
if (!(error instanceof AgendaFetchError)) throw error;
return NextResponse.json({ error: error.message }, { status: 502 });
}
const valid = occurrences
.filter((e) => (e['@type'] ?? 'Event') === 'Event')
// Drop malformed events without a parseable start (would crash format()/
// parseISO downstream) — mirrors the calendar store guard (#316).
.filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[];
const expanded = hydrateRecurrenceInstances(valid, bases);
// ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ──
const agenda: AgendaEvent[] = expanded
.filter((e) => eventEnd(e).getTime() >= now.getTime())
@@ -277,9 +312,10 @@ export async function POST(request: NextRequest) {
.slice(0, limit)
.map((e) => {
const calId = firstCalendarId(e);
const cal = calId ? calColors.get(calId) : undefined;
const cal = calId ? calColors.get(scopedId(e.accountId, calId)) : undefined;
return {
id: String(e.id ?? ''),
id: scopedId(e.accountId, String(e.id ?? '')),
accountId: e.accountId,
uid: e.uid ?? null,
title: (e.title ?? '').trim() || '(no title)',
start: eventStart(e).toISOString(),
@@ -306,7 +342,7 @@ export async function POST(request: NextRequest) {
}
}
/** A method error on one of the agenda's gets, answered with 502. */
/** A method error on one of the agenda's gets: 502 on the primary account. */
class AgendaFetchError extends Error {}
/**
@@ -319,6 +355,31 @@ function isOccurrenceLimit(err: Record<string, unknown> | null): boolean {
return err?.type === 'invalidArguments' && /exceeds/i.test(String(err.description ?? ''));
}
/**
* The accounts to read calendars from, primary first. Mirrors the client's
* getCalendarCapableAccountIds(): shared and group accounts count even when
* Stalwart does not advertise the calendar capability on them.
*/
function calendarAccountIds(session: {
primaryAccounts?: Record<string, string>;
accounts?: Record<string, unknown>;
}): string[] {
const primary = session.primaryAccounts?.[CALENDAR_CAP];
const ids: string[] = primary ? [primary] : [];
for (const [id, value] of Object.entries(session.accounts ?? {})) {
if (id === primary || !value || typeof value !== 'object') continue;
const account = value as { isPersonal?: unknown; accountCapabilities?: Record<string, unknown> };
if (account.accountCapabilities?.[CALENDAR_CAP] || account.isPersonal === false) {
ids.push(id);
}
}
return ids;
}
function scopedId(accountId: string, id: string): string {
return `${accountId}:${id}`;
}
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return fallback;
+16 -5
View File
@@ -4,6 +4,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { getOauthScopes } from '@/lib/oauth/tokens';
import { httpUrlOrEmpty } from '@/lib/config-validation';
import {
matchDomainBranding,
parseDomainBranding,
@@ -53,6 +54,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(
{
@@ -82,19 +84,28 @@ export async function GET(request: NextRequest) {
loginShowHeading: configManager.get<boolean>('loginShowHeading', true),
loginShowSubtitle: configManager.get<boolean>('loginShowSubtitle', true),
loginShowTotp: configManager.get<boolean>('loginShowTotp', true),
loginShowTokenLogin: configManager.get<boolean>('loginShowTokenLogin', false),
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', ''),
sourceCodeUrl: httpUrlOrEmpty(configManager.get<string>('sourceCodeUrl', '')),
},
{
// Branding varies by host, so any cache between us and the browser
// must key its entry by the host headers we consulted.
headers: { Vary: 'Host, X-Forwarded-Host' },
// Runtime settings can change without new asset URLs. Prevent stale
// browser/proxy responses, retaining Vary for host-specific branding.
headers: {
Vary: 'Host, X-Forwarded-Host',
'Cache-Control': 'no-store, no-cache, must-revalidate',
'CDN-Cache-Control': 'no-store',
},
},
);
}
+6 -2
View File
@@ -1795,7 +1795,7 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult {
from: (data.from as MockEmail['from']) || [{ name: 'Dev User', email: 'dev@localhost' }],
to: (data.to as MockEmail['to']) || [],
cc: (data.cc as MockEmail['cc']) || [],
subject: (data.subject as string) || '(no subject)',
subject: typeof data.subject === 'string' ? data.subject : '',
preview: (previewText || (data.subject as string) || '').slice(0, 120),
hasAttachment: false,
textBody: [],
@@ -2538,7 +2538,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();
+46 -4
View File
@@ -1,6 +1,7 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { cleanPreview } from '@/lib/utils';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import {
@@ -19,6 +20,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 +61,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;
}
@@ -154,10 +161,16 @@ export async function GET(request: NextRequest) {
};
const inboxBody = inboxData.methodResponses.find(
([method]) => method === 'Mailbox/query',
([method, , callId]) => method === 'Mailbox/query' && callId === 'mb',
)?.[1] as { ids?: string[] } | undefined;
const inboxId = inboxBody?.ids?.[0];
// A method-level JMAP error still has HTTP 200. Do not turn it into
// an empty Inbox: the service worker would silently discard the push.
if (!Array.isArray(inboxBody?.ids)) {
return NextResponse.json({ error: 'JMAP mailbox query failed' }, { status: 502 });
}
const inboxId = inboxBody.ids[0];
const emailProperties = ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt'];
if (!inboxId && !requestedEmailId) {
@@ -232,6 +245,21 @@ export async function GET(request: NextRequest) {
methodResponses: [string, Record<string, unknown>, string][];
};
// Missing/failed method responses are preview failures, not proof that
// there is no unread mail. A non-2xx response lets the SW show its fallback.
// The message the server named, once read, is a complete preview on its
// own, whatever became of the Inbox lookups next to it.
const resultOf = (name: string, id: string) =>
data.methodResponses.find(([method, , callId]) => method === name && callId === id)?.[1];
const failed = (methodCalls as [string, unknown, string][]).some(([name, , id]) => {
const result = resultOf(name, id);
return !result || (name === 'Email/query' ? !Array.isArray(result.ids) : !Array.isArray(result.list));
});
const deliveredList = resultOf('Email/get', 'delivered')?.list;
if (failed && !(Array.isArray(deliveredList) && deliveredList.length > 0)) {
return NextResponse.json({ error: 'JMAP email query failed' }, { status: 502 });
}
type EmailLite = {
id: string;
threadId: string;
@@ -246,7 +274,9 @@ export async function GET(request: NextRequest) {
let unreadTotal = 0;
for (const [method, body, callId] of data.methodResponses) {
if (method === 'Email/query') {
unreadTotal = ((body as { total?: number }).total) ?? 0;
// A server that leaves out the optional total still lists the ids.
const query = body as { total?: number; ids?: unknown[] };
unreadTotal = typeof query.total === 'number' ? query.total : (query.ids?.length ?? 0);
}
if (method === 'Email/get') {
const list = (body as { list?: EmailLite[] }).list ?? [];
@@ -263,11 +293,23 @@ export async function GET(request: NextRequest) {
unreadTotal = Math.max(unreadTotal, 1);
}
email = delivered;
} else if (requestedEmailId) {
// The server named the message that arrived and it could not be read -
// deleted since, or filed where this login cannot see it. "Newest unread
// in the Inbox" is then some other message, and announcing it would be
// confidently wrong. Name no message: the unread total still comes back,
// so the SW posts its generic toast instead of staying silent.
email = null;
}
// Some servers build the preview from HTML without skipping <style>; a
// notification that opens on a style sheet says nothing.
if (email?.preview) email = { ...email, preview: cleanPreview(email.preview) };
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
+30 -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';
@@ -8,6 +9,7 @@ import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/se
import { configManager } from '@/lib/admin/config-manager';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { mergeStoredTemplates } from '@/lib/template-merge';
function classifyError(error: unknown): { message: string; status: number } {
const code = (error as NodeJS.ErrnoException).code;
@@ -66,9 +68,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 +83,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 +109,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 +127,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 +145,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 +177,10 @@ export async function POST(request: NextRequest) {
}
}
await saveUserSettings(username, serverUrl, filteredSettings);
// Templates are merged into the stored blob rather than replaced, so a
// stale tab or device cannot drop templates another one added.
const stored = await loadUserSettings(accountName, serverUrl);
await saveUserSettings(accountName, serverUrl, mergeStoredTemplates(stored, filteredSettings));
return NextResponse.json({ ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
@@ -178,6 +192,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 +206,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,
},
+10 -2
View File
@@ -1,4 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { readFileEnv } from "@/lib/read-file-env";
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
@@ -11,7 +13,7 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
// langpair needs an explicit source language, so when the
// plugin asks for "auto" we detect it locally first.
// - "libretranslate" — only available when the host sets LIBRETRANSLATE_URL
// (and optionally LIBRETRANSLATE_API_KEY). Supports native
// (and optionally LIBRETRANSLATE_API_KEY or LIBRETRANSLATE_API_KEY_FILE). Supports native
// source auto-detection.
export const runtime = 'nodejs';
@@ -225,7 +227,9 @@ async function translateLibre(
if (!endpoint) {
throw new Error('LibreTranslate is not configured on this server');
}
const apiKey = process.env.LIBRETRANSLATE_API_KEY;
const apiKey =
process.env.LIBRETRANSLATE_API_KEY ||
readFileEnv(process.env.LIBRETRANSLATE_API_KEY_FILE);
const url = endpoint.replace(/\/+$/, '') + '/translate';
const res = await fetch(url, {
method: 'POST',
@@ -257,6 +261,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) {
+26 -4
View File
@@ -6,15 +6,38 @@ import { wopiContext } from '@/lib/wopi/request';
/**
* WOPI GetFile / PutFile (#425). Content flows purely over JMAP: GetFile
* 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 and
* points the FileNode at it via `FileNode/set { blobId, modified: null }`.
* 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, modified: null }`.
*/
/**
* 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: documentId } = await params;
const auth = await wopiContext(request, documentId);
@@ -41,8 +64,7 @@ export async function GET(
return NextResponse.json({ error: 'Blob download failed' }, { status: 502 });
}
const headers = new Headers();
headers.set('Content-Type', source.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 });
+12 -2
View File
@@ -12,6 +12,14 @@ import { wopiContext } from '@/lib/wopi/request';
* cookie on these requests.
*/
/**
* ONLYOFFICE treats a document the user may not edit as forced into view
* mode and warns about it ("Error code: -103" on 9.1), even when the document
* was opened through the view action. UserCanReview keeps it off that path,
* and with SupportsReviewing false it grants nothing (#1187).
*/
const VIEW_ONLY_INFO = { UserCanReview: true, SupportsReviewing: false };
/** GET = CheckFileInfo */
export async function GET(
request: NextRequest,
@@ -39,8 +47,9 @@ export async function GET(
UserCanNotWriteRelative: true,
SupportsUpdate: false,
SupportsLocks: false,
...VIEW_ONLY_INFO,
Version: payload.fileId,
PostMessageOrigin: payload.origin,
PostMessageOrigin: payload.postMessageOrigin || payload.origin,
});
}
@@ -59,9 +68,10 @@ export async function GET(
UserCanNotWriteRelative: true,
SupportsUpdate: true,
SupportsLocks: false,
...(auth.payload.canWrite ? {} : VIEW_ONLY_INFO),
LastModifiedTime: node.modified,
Version: node.blobId,
PostMessageOrigin: auth.payload.origin,
PostMessageOrigin: auth.payload.postMessageOrigin || auth.payload.origin,
});
} catch (error) {
logger.error('WOPI CheckFileInfo failed', {
+20 -7
View File
@@ -1,4 +1,6 @@
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';
@@ -6,11 +8,14 @@ import { fetchJmapSession, type JmapSessionDocument } from '@/lib/stalwart/jmap-
import { getWopiActions, buildWopiActionUrl } from '@/lib/wopi/discovery';
import { getWopiFileNode, probeBlob, shareeSavesNeedRename } from '@/lib/wopi/files';
import { mintWopiToken, wopiDocumentId, type WopiTokenPayload } from '@/lib/wopi/token';
import { wopiBrowserBinding } from '@/lib/wopi/revocation';
import { wopiBrowserOrigin, wopiHostBase } from '@/lib/wopi/origin';
/**
* POST /api/wopi/launch
* { fileId: string, accountId?: string } - a Files node
* { blobId: string, name: string, type?: string, accountId?: string } - a mail attachment
* plus an optional `lang`, the webmail's locale, for the editor's own UI.
*
* 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). A
@@ -20,6 +25,10 @@ import { mintWopiToken, wopiDocumentId, type WopiTokenPayload } from '@/lib/wopi
* 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) {
@@ -57,6 +66,9 @@ export async function POST(request: NextRequest) {
let accountId = str('accountId');
let ownAccountId = '';
let session: JmapSessionDocument | null = null;
// 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).
if (!accountId || !blobId) {
session = await fetchJmapSession(serverUrl, creds.authHeader, { trusted });
const capability = blobId ? 'urn:ietf:params:jmap:mail' : 'urn:ietf:params:jmap:filenode';
@@ -70,7 +82,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: blobId ? 'No mail account' : 'No files account' }, { status: 404 });
}
let document: Pick<WopiTokenPayload, 'kind' | 'fileId' | 'name' | 'type' | 'size'>;
let document: Pick<WopiTokenPayload, 'kind' | 'fileId' | 'name' | 'type' | 'size' | 'uploadAccountId'>;
let name: string;
let canWrite: boolean;
if (blobId) {
@@ -96,7 +108,7 @@ export async function POST(request: NextRequest) {
const rights = node.myRights;
const needsRename = accountId !== ownAccountId && shareeSavesNeedRename(session, accountId, ownAccountId);
canWrite = !rights || (!!rights.mayModifyContent && (!needsRename || !!rights.mayRename));
document = { kind: 'file', fileId };
document = { kind: 'file', fileId, uploadAccountId: ownAccountId || accountId };
}
const ext = name.split('.').pop()?.toLowerCase() || '';
@@ -109,24 +121,25 @@ export async function POST(request: NextRequest) {
// Where the editor reaches this webmail. Deployments where the editor
// sees a different host than the browser (docker networks, split DNS)
// override it via wopiHostUrl.
const hostBase =
configManager.get<string>('wopiHostUrl', '').trim().replace(/\/+$/, '') ||
request.nextUrl.origin;
const hostBase = wopiHostBase(request, configManager.get<string>('wopiHostUrl', ''));
const tokenPayload = {
serverUrl,
authHeader: creds.authHeader,
username: creds.username,
accountId,
...(blobId ? {} : { uploadAccountId: ownAccountId || accountId }),
...document,
canWrite: editable,
origin: request.nextUrl.origin,
postMessageOrigin: wopiBrowserOrigin(request),
// 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),
url: buildWopiActionUrl(urlsrc, wopiSrc, str('lang')),
accessToken: token,
accessTokenTtl: expiresAt,
readOnly: !editable,
+309
View File
@@ -199,10 +199,57 @@ option {
color: var(--color-popover-foreground);
}
/* The group labels (the From dropdown's accounts) are painted the same way.
Without colours of their own they kept the control's text colour on the
popup's base, which a transparent control leaves light: in dark themes,
light text on a white bar. */
optgroup {
background-color: var(--color-popover);
color: var(--color-popover-foreground);
}
.dark select {
color-scheme: dark;
}
/* Tailwind v4's preflight no longer gives buttons a pointer cursor, so most
controls showed the arrow. Give everything clickable the pointer again, and
disabled controls not-allowed, as the components that style it themselves
already do. This sits in the base layer so a cursor utility on a component
(cursor-grab, cursor-text, disabled:cursor-default) still wins. */
@layer base {
button:not(:disabled),
select:not(:disabled),
input:is([type="checkbox"], [type="radio"], [type="color"]):not(:disabled),
summary,
[role="button"]:not([aria-disabled="true"]),
[role="tab"]:not([aria-disabled="true"]),
[role="switch"]:not([aria-disabled="true"]),
[role="checkbox"]:not([aria-disabled="true"]),
[role="radio"]:not([aria-disabled="true"]),
[role="option"]:not([aria-disabled="true"]),
[role="menuitem"]:not([aria-disabled="true"]),
[role="menuitemcheckbox"]:not([aria-disabled="true"]),
[role="menuitemradio"]:not([aria-disabled="true"]) {
cursor: pointer;
}
button:disabled,
select:disabled,
input:is([type="checkbox"], [type="radio"], [type="color"]):disabled,
[role="button"][aria-disabled="true"],
[role="tab"][aria-disabled="true"],
[role="switch"][aria-disabled="true"],
[role="checkbox"][aria-disabled="true"],
[role="radio"][aria-disabled="true"],
[role="option"][aria-disabled="true"],
[role="menuitem"][aria-disabled="true"],
[role="menuitemcheckbox"][aria-disabled="true"],
[role="menuitemradio"][aria-disabled="true"] {
cursor: not-allowed;
}
}
/* Minimalist scrollbar */
::-webkit-scrollbar {
width: 8px;
@@ -705,6 +752,16 @@ option {
}
}
/* The same when the user turns animations off in the settings (#1215). */
[data-animations="off"] *,
[data-animations="off"] *::before,
[data-animations="off"] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/* Disable backdrop-filter on mobile for performance */
@media (max-width: 767px) {
.mobile-backdrop {
@@ -901,3 +958,255 @@ option {
[dir="rtl"] .tabler-icon-layout-sidebar-right {
transform: scaleX(-1);
}
/* ==========================================================================
Interface layouts
--------------------------------------------------------------------------
`data-interface-layout` is set on <html> by the settings store
(`applyInterfaceLayout`). The default layout declares no overrides, so
everything below is additive and switched off by a single attribute.
Mountain View sets its own geometry - bar heights, rail widths, pill shapes,
row rhythm - while colour, iconography and type keep coming from the active
theme.
========================================================================== */
[data-interface-layout="mountain-view"] {
--mv-topbar-h: 64px;
--mv-rail-w: 72px;
--mv-rail-peek-w: 256px;
--mv-nav-row-h: 32px;
--mv-nav-radius: 9999px;
--mv-compose-h: 56px;
--mv-search-h: 48px;
--mv-search-radius: 8px;
--mv-popover-w: 352px;
--mv-popover-radius: 28px;
--mv-card-radius: 16px;
/* Surfaces, tinted from the theme's primary so any theme keeps the same
relationships: a faintly tinted canvas behind the chrome, a plain card
for the content, a slightly deeper search field, a selected-row pill and
a soft Compose button. */
--mv-canvas: color-mix(in oklab, var(--color-primary) 4%, var(--color-background));
--mv-search-bg: color-mix(in oklab, var(--color-primary) 9%, var(--color-background));
--mv-nav-hover: color-mix(in oklab, var(--color-foreground) 7%, var(--mv-canvas));
--mv-nav-selected: color-mix(in oklab, var(--color-primary) 18%, var(--color-background));
--mv-compose-bg: color-mix(in oklab, var(--color-primary) 24%, var(--color-background));
--mv-compose-fg: color-mix(in oklab, var(--color-primary) 30%, var(--color-foreground));
--mv-row-read: color-mix(in oklab, var(--color-primary) 3%, var(--color-background));
}
/* The content card: everything that is not chrome sits on one rounded sheet
inset from the canvas. */
[data-interface-layout="mountain-view"] [data-mv-card] {
border-radius: var(--mv-card-radius);
background-color: var(--color-background);
overflow: hidden;
}
/* Search: a filled 48px field that lifts onto a card while focused, rather
than the outlined input the default layout uses. */
[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input] {
height: var(--mv-search-h);
border-radius: var(--mv-search-radius);
border-color: transparent;
background-color: var(--mv-search-bg);
padding-inline-start: 3rem;
font-size: 1rem;
}
[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input]:hover {
border-color: transparent;
background-color: var(--mv-search-bg);
box-shadow: 0 1px 1px 0 rgb(0 0 0 / 0.08), 0 1px 3px 1px rgb(0 0 0 / 0.04);
}
[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input]:focus-visible {
background-color: var(--color-background);
border-color: transparent;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.16), 0 4px 8px 3px rgb(0 0 0 / 0.08);
}
/* The filter button sits inside the field's trailing end: keep typed text and
the clear button clear of it. */
[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input] {
padding-inline-end: 5.5rem;
}
[data-interface-layout="mountain-view"] .mv-topbar-search > div > form > button {
inset-inline-end: 3.25rem;
}
/* The magnifier sits on the field's baseline grid, one 48px hit area in. */
[data-interface-layout="mountain-view"] .mv-topbar-search > div > form > svg {
inset-inline-start: 1rem;
width: 1.25rem;
height: 1.25rem;
}
/* Folder rows: 32px pills flush with the rail's leading edge and rounded only
on the trailing side, so the selected label reads as one continuous shape
with the rail. The default layout's leading accent stripe goes away with it. */
[data-interface-layout="mountain-view"] [data-mv-nav-row] {
min-height: var(--mv-nav-row-h);
margin-inline-end: 12px;
padding-inline-end: 16px;
border-inline-start-width: 0;
border-start-start-radius: 0;
border-end-start-radius: 0;
border-start-end-radius: var(--mv-nav-radius);
border-end-end-radius: var(--mv-nav-radius);
}
/* Selected and hovered rows fill the pill; a folder with unread mail reads
bold, the way the message rows do. Icons are 20px with an 18px gap. */
[data-interface-layout="mountain-view"] [data-mv-nav-row] {
font-size: 0.875rem;
color: var(--color-foreground);
}
[data-interface-layout="mountain-view"] [data-mv-nav-row]:hover {
background-color: var(--mv-nav-hover);
}
[data-interface-layout="mountain-view"] [data-mv-nav-row][data-selected="true"] {
background-color: var(--mv-nav-selected);
color: var(--color-foreground);
font-weight: 700;
}
[data-interface-layout="mountain-view"] [data-mv-nav-row][data-has-unread] {
font-weight: 700;
}
[data-interface-layout="mountain-view"] [data-mv-nav-row] > button {
gap: 18px;
}
[data-interface-layout="mountain-view"] [data-mv-nav-row] > button > span:first-child,
[data-interface-layout="mountain-view"] [data-mv-nav-row] > button > span:first-child > svg {
width: 20px;
height: 20px;
}
/* Compose: a 56px button with a 16px corner, which shrinks to a square with
the rail. */
[data-interface-layout="mountain-view"] [data-mv-compose] {
height: var(--mv-compose-h);
border-radius: 16px;
font-weight: 500;
}
/* Message rows: read mail sits on a faint tint and unread on the plain card,
a ticked row takes the selection colour, and the row under the pointer
lifts on a shadow instead of changing colour. */
[data-interface-layout="mountain-view"] [data-mv-row] {
background-color: var(--mv-row-read);
box-shadow: none;
}
[data-interface-layout="mountain-view"] [data-mv-row][data-unread="true"] {
background-color: var(--color-background);
}
[data-interface-layout="mountain-view"] [data-mv-row][data-checked] {
background-color: var(--mv-nav-selected);
}
[data-interface-layout="mountain-view"] [data-mv-row][aria-current="true"] {
background-color: var(--color-accent);
}
[data-interface-layout="mountain-view"] [data-mv-row]:hover {
z-index: 1;
box-shadow:
inset 1px 0 0 var(--color-border),
inset -1px 0 0 var(--color-border),
0 1px 2px 0 rgb(60 64 67 / 0.3),
0 1px 3px 1px rgb(60 64 67 / 0.15);
}
/* The open message's toolbar: round 40px icon buttons in the muted colour,
20px icons. Menus that drop from it keep their own rows. */
[data-interface-layout="mountain-view"] [data-mv-viewer-toolbar] button:not([role="menu"] *):not([data-mv-pager] *) {
height: 40px;
min-width: 40px;
padding-inline: 10px;
border-radius: 9999px;
color: var(--color-muted-foreground);
}
[data-interface-layout="mountain-view"] [data-mv-viewer-toolbar] button:not([role="menu"] *):not([data-mv-pager] *):hover {
color: var(--color-foreground);
}
[data-interface-layout="mountain-view"] [data-mv-viewer-toolbar] button:not([role="menu"] *) > svg {
width: 20px;
height: 20px;
}
/* The compose window's bottom bar: its two halves fold into one row, Send
leads as a round split button without its icon, the tools follow as bare
icons, and the bin for Discard sits at the far end. */
[data-mv-compose-footer] > * {
display: contents;
}
[data-mv-compose-footer] > * > * {
order: 1;
}
[data-mv-compose-footer] [data-mv-send] {
order: 0;
margin-inline-end: 0.5rem;
}
[data-mv-compose-footer] [data-mv-discard] {
order: 2;
margin-inline-start: auto;
}
[data-mv-compose-footer] [data-mv-send] .tabler-icon-send {
display: none;
}
[data-mv-compose-footer] button[data-mv-send],
[data-mv-compose-footer] [data-mv-send] > button:first-child {
height: 36px;
padding-inline: 1.25rem 1rem;
border-radius: 18px;
}
[data-mv-compose-footer] [data-mv-send] > button:first-child:not(:only-child) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
[data-mv-compose-footer] [data-mv-send] > button:nth-child(2) {
height: 36px;
border-radius: 0 18px 18px 0;
}
[dir="rtl"] [data-mv-compose-footer] [data-mv-send] > button:nth-child(2) {
border-radius: 18px 0 0 18px;
}
[data-mv-compose-footer] button:not([data-mv-send] *):not([data-mv-send]) > span.text-sm,
[data-mv-compose-footer] button[data-testid$="-menu"] > .tabler-icon-chevron-down {
display: none;
}
[data-mv-compose-footer] button[data-testid$="-menu"] {
position: relative;
width: 36px;
padding-inline: 0;
justify-content: center;
border-radius: 9999px;
}
[data-mv-compose-footer] button[data-testid$="-menu"] > span:not(.text-sm) {
position: absolute;
top: 0;
inset-inline-end: 0;
}
/* The account popover: a wide card with a very round corner, rather
than the narrow menu the rail and the sidebar switchers drop. */
[data-interface-layout="mountain-view"] [data-mv-account-popover] {
width: var(--mv-popover-w);
border-radius: var(--mv-popover-radius);
}
/* The advanced filters drop from the search field with a small gap and take
its corner, so the two read as one control. Position and width come from
the field's own box, measured by FilterPanelHost. */
[data-interface-layout="mountain-view"] [data-mv-filter-panel] {
border-radius: var(--mv-search-radius);
}
/* The collapsed rail's hover peek. Only on desktop: below that breakpoint the
sidebar is already a drawer, opened by a tap rather than by a pointer that
does not exist. The width beats Tailwind's own `lg:w-full` on specificity,
which is what keeps the floating panel from filling the 72px column. */
@media (min-width: 1024px) {
[data-interface-layout="mountain-view"] [data-mv-rail-peek] {
width: var(--mv-rail-peek-w);
}
}
@@ -18,7 +18,12 @@ const mocks = vi.hoisted(() => ({
username: "alice@example.com",
isAuthenticated: true,
isDemoMode: false,
connectedAccountsRevision: 0,
// Login id -> client, as the auth store keeps it for the account switcher.
connected: new Map<string, { getAccountId: () => string }>(),
getAllConnectedClients: () => new Map(mocks.authState.connected),
},
accounts: [] as { id: string; username: string }[],
settingsState: { emailNotificationsEnabled: true, pushRelayUrl: "" },
policyState: {
loaded: true,
@@ -35,8 +40,15 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@/stores/auth-store", () => ({
useAuthStore: (selector: (state: typeof mocks.authState) => unknown) =>
selector(mocks.authState),
useAuthStore: Object.assign(
(selector: (state: typeof mocks.authState) => unknown) =>
selector(mocks.authState),
{ getState: () => mocks.authState },
),
}));
vi.mock("@/stores/account-store", () => ({
useAccountStore: { getState: () => ({ accounts: mocks.accounts }) },
}));
vi.mock("@/stores/settings-store", () => ({
@@ -83,6 +95,9 @@ describe("PushNotificationPrompt", () => {
mocks.supported = true;
mocks.enabled = false;
mocks.authState.client = { getAccountId: () => "account-1" };
mocks.authState.connectedAccountsRevision = 0;
mocks.authState.connected = new Map([["alice@mail.example.com", mocks.authState.client]]);
mocks.accounts = [{ id: "alice@mail.example.com", username: "alice@example.com" }];
mocks.authState.username = "alice@example.com";
mocks.authState.isAuthenticated = true;
mocks.authState.isDemoMode = false;
@@ -216,6 +231,63 @@ describe("PushNotificationPrompt", () => {
expect(mocks.enableWebPush).not.toHaveBeenCalled();
});
it("renews push for every connected login, not only the active one", async () => {
// Push for bob was enabled from the all-accounts panel; he never becomes
// the active account, so only this background pass keeps his
// registration from hitting the server's 7-day expiry.
const bob = { getAccountId: () => "account-2" };
mocks.authState.connected.set("bob@mail.example.com", bob);
mocks.accounts.push({ id: "bob@mail.example.com", username: "bob@example.com" });
render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.resyncWebPush).toHaveBeenCalledTimes(2);
expect(mocks.resyncWebPush.mock.calls[0][0]).toMatchObject({
client: mocks.authState.client,
accountLabel: "alice@example.com",
});
expect(mocks.resyncWebPush.mock.calls[1][0]).toMatchObject({
client: bob,
accountLabel: "bob@example.com",
relayBaseUrl: "https://notifications.relay.bulwarkmail.org",
});
});
it("renews the other logins again when the tab returns to the foreground", async () => {
const bob = { getAccountId: () => "account-2" };
mocks.authState.connected.set("bob@mail.example.com", bob);
render(<PushNotificationPrompt />);
await advancePromptDelay();
mocks.resyncWebPush.mockClear();
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.resyncWebPush.mock.calls.map(([p]) => p.client)).toEqual([
mocks.authState.client,
bob,
]);
});
it("reaches logins that finish connecting after the first pass", async () => {
const { rerender } = render(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.resyncWebPush).toHaveBeenCalledTimes(1);
// The background restore connects bob and bumps the revision.
const bob = { getAccountId: () => "account-2" };
mocks.authState.connected.set("bob@mail.example.com", bob);
mocks.authState.connectedAccountsRevision = 1;
rerender(<PushNotificationPrompt />);
await advancePromptDelay();
expect(mocks.resyncWebPush.mock.calls.map(([p]) => p.client)).toContain(bob);
});
it("does not touch push in demo mode", async () => {
mocks.authState.isDemoMode = true;
render(<PushNotificationPrompt />);
@@ -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,156 @@
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { CalendarWeekView } from '../calendar-week-view';
import { CalendarDayView } from '../calendar-day-view';
import { useSettingsStore } from '@/stores/settings-store';
import type { CalendarEvent, Calendar } from '@/lib/jmap/types';
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
function makeEvent(id: string, start: string, duration: string, showWithoutTime = false): CalendarEvent {
return {
id,
'@type': 'Event',
uid: id,
title: 'Event ' + id,
start,
duration,
showWithoutTime,
calendarIds: { 'cal-1': true },
} as unknown as CalendarEvent;
}
type WeekProps = React.ComponentProps<typeof CalendarWeekView>;
function renderWeek(overrides: Partial<WeekProps> = {}) {
const props: WeekProps = {
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 render(<CalendarWeekView {...props} />);
}
function eventBlock(id: string): HTMLElement | null {
// The positioned wrapper around the card, not the card itself
return screen.queryByText('Event ' + id)?.closest<HTMLElement>('[class*="group/event"]') ?? null;
}
const initialSettings = useSettingsStore.getState();
describe('calendar display hours and working days (#1164)', () => {
beforeEach(() => {
useSettingsStore.setState(initialSettings, true);
});
afterEach(() => {
cleanup();
});
it('shows 08:00 to 20:00 by default', () => {
renderWeek();
expect(screen.getAllByRole('gridcell')).toHaveLength(7 * 12);
expect(screen.getByText('08:00')).toBeInTheDocument();
expect(screen.queryByText('07:00')).toBeNull();
expect(screen.queryByText('20:00')).toBeNull();
});
it('shows the whole day when the limit is off', () => {
useSettingsStore.setState({ calendarLimitHours: false });
renderWeek();
expect(screen.getAllByRole('gridcell')).toHaveLength(7 * 24);
expect(document.querySelector('[data-all-hours-toggle]')).toBeNull();
});
it('positions events from the first visible hour and cuts them at the edges', () => {
renderWeek({
events: [
makeEvent('morning', '2026-09-09T07:00:00', 'PT2H'),
makeEvent('noon', '2026-09-09T12:00:00', 'PT1H'),
],
});
// 07:00-09:00 shows as 08:00-09:00 at the top of the grid
expect(eventBlock('morning')?.style.top).toBe('0px');
expect(eventBlock('morning')?.style.height).toBe('60px');
expect(eventBlock('noon')?.style.top).toBe('240px');
});
it('marks hidden events and shows the whole day from the marker', () => {
renderWeek({
events: [
makeEvent('early', '2026-09-09T06:00:00', 'PT1H'),
makeEvent('late', '2026-09-09T21:00:00', 'PT1H'),
makeEvent('later', '2026-09-09T22:00:00', 'PT1H'),
],
});
expect(eventBlock('early')).toBeNull();
const before = document.querySelector<HTMLElement>('[data-hidden-events="before"]');
const after = document.querySelector<HTMLElement>('[data-hidden-events="after"]');
expect(before).toHaveTextContent('1');
expect(after).toHaveTextContent('2');
fireEvent.click(before!);
expect(screen.getAllByRole('gridcell')).toHaveLength(7 * 24);
expect(eventBlock('early')?.style.top).toBe('360px');
expect(document.querySelector('[data-hidden-events]')).toBeNull();
// ...and back to the configured hours
const toggle = document.querySelector<HTMLElement>('[data-all-hours-toggle]')!;
expect(toggle).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(toggle);
expect(screen.getAllByRole('gridcell')).toHaveLength(7 * 12);
});
it('leaves non-working days out of the week view', () => {
useSettingsStore.setState({ calendarHideNonWorkingDays: true, calendarWorkingDays: [1, 2, 3, 4, 5] });
renderWeek({
events: [
makeEvent('weekend', '2026-09-12T10:00:00', 'PT1H'),
// Friday to Monday, all day: still drawn on the Friday column
makeEvent('trip', '2026-09-11', 'P4D', true),
],
rangeEnd: new Date(2026, 8, 14),
});
const headers = screen.getAllByRole('columnheader');
expect(headers.map((h) => h.getAttribute('data-day'))).toEqual([
'2026-09-07', '2026-09-08', '2026-09-09', '2026-09-10', '2026-09-11', '2026-09-14',
]);
expect(eventBlock('weekend')).toBeNull();
const trip = screen.getByText('Event trip').closest<HTMLElement>('.absolute')!;
// Column 5 of 6, two columns wide (jsdom rounds the percentages)
expect(trip.style.left).toBe('calc(66.6667% + 1px)');
expect(trip.style.width).toBe('calc(33.3333% - 2px)');
});
it('applies the visible hours to the day view too', () => {
render(
<CalendarDayView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="day:2026-09-09"
rangeStart={new Date(2026, 8, 9)}
rangeEnd={new Date(2026, 8, 9)}
selectedDate={new Date(2026, 8, 9)}
events={[
makeEvent('early', '2026-09-09T06:00:00', 'PT1H'),
makeEvent('noon', '2026-09-09T12:00:00', 'PT1H'),
]}
calendars={calendars}
onSelectEvent={vi.fn()}
onCreateAtTime={vi.fn()}
/>,
);
expect(screen.getAllByRole('gridcell')).toHaveLength(12);
expect(eventBlock('early')).toBeNull();
expect(eventBlock('noon')?.style.top).toBe(`${4 * 64}px`);
expect(document.querySelector('[data-hidden-events="before"]')).toHaveTextContent('1');
});
});
@@ -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,181 @@
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';
import { useSettingsStore } from '@/stores/settings-store';
// #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('keeps a crowded day to its row height and links to the rest', () => {
const onOpenDay = vi.fn();
renderView({
events: [makeAllDayEvent('e1', '2026-09-10')],
tasks: ['a', 'b', 'c'].map((id) => makeTask(id, '2026-09-10')),
onOpenDay,
});
const row = document.querySelector<HTMLElement>('[data-week="2026-09-07"]');
// jsdom measures no viewport, so rows keep the 100px minimum: room for
// two 22px rows under the 30 + 4 offset, the second taken by the link.
expect(row?.style.minHeight).toBe('100px');
expect(screen.getByText('Event e1')).toBeInTheDocument();
expect(document.querySelector('[data-calendar-task="a"]')).toBeNull();
const more = screen.getByRole('button', { name: 'events.more' });
expect(more.parentElement?.style.top).toBe('22px');
fireEvent.click(more);
expect(onOpenDay).toHaveBeenCalledWith(new Date(2026, 8, 10));
});
it('grows the week row to fit a crowded day on phones', () => {
useSettingsStore.setState({ showTimeInMonthView: true });
try {
renderView({
events: [makeAllDayEvent('e1', '2026-09-10')],
tasks: ['a', 'b', 'c'].map((id) => makeTask(id, '2026-09-10')),
isMobile: true,
});
const row = document.querySelector<HTMLElement>('[data-week="2026-09-07"]');
// overlay offset 34 + 4, one event row and three task rows of 18px, 8 below.
expect(row?.style.minHeight).toBe(`${34 + 4 + 4 * 18 + 8}px`);
expect(chipSlot('c').style.top).toBe(`${3 * 18}px`);
} finally {
useSettingsStore.setState({ showTimeInMonthView: false });
}
});
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');
});
});
@@ -0,0 +1,53 @@
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { EventModal } from '../event-modal';
import { calendarFormHooks, removeAllPluginHooks } from '@/lib/plugin-hooks';
import type { Calendar } from '@/lib/jmap/types';
// The form-save hook runs before the event is saved. A plugin handler can take
// a while (minting a meeting room), and the save must count as in flight for
// that whole time, or a second click creates the new event twice.
const calendars = [
{ id: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } },
] as unknown as Calendar[];
afterEach(() => {
removeAllPluginHooks('test-plugin');
});
describe('EventModal form-save hook', () => {
it('saves once when Save is pressed again while a plugin handler is running', async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
calendarFormHooks.onCalendarEventFormSave.register('test-plugin', async () => {
await gate;
return { virtualLocation: 'https://meet.example.com/room-1' };
});
const onSave = vi.fn().mockResolvedValue(undefined);
render(
<EventModal
calendars={calendars}
defaultDate={new Date('2026-10-12T10:00:00')}
defaultCalendarId="cal-1"
onSave={onSave}
onClose={vi.fn()}
/>,
);
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Sync' } });
const save = screen.getByText('form.save');
fireEvent.click(save);
fireEvent.click(save);
fireEvent.keyDown(window, { key: 'Enter', ctrlKey: true });
await act(async () => { release(); });
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
// Give a stray second save the chance to show up.
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
expect(onSave).toHaveBeenCalledTimes(1);
const data = onSave.mock.calls[0][0];
expect(Object.values(data.virtualLocations)[0]).toMatchObject({ uri: 'https://meet.example.com/room-1' });
});
});
@@ -0,0 +1,40 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import React from 'react';
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: () => [{ name: 'User B', email: 'userb@example.org' }],
};
const hook = (sel?: (s: typeof state) => unknown) => (typeof sel === 'function' ? sel(state) : state);
hook.getState = () => state;
return { useContactStore: hook };
});
const { ParticipantInput } = await import('../participant-input');
describe('ParticipantInput', () => {
it('is a search field, which Bitwarden never offers to fill', () => {
// Bitwarden offered saved logins for the e-mail placeholder.
render(<ParticipantInput participants={[]} onAdd={vi.fn()} onRemove={vi.fn()} />);
expect(screen.getByRole('combobox')).toHaveAttribute('type', 'search');
});
it('shows its suggestions outside the scrolling event form', async () => {
// The form scrolls, and its overflow cut off the left of the list, where
// the avatars are.
render(
<div data-testid="form" style={{ overflowY: 'auto' }}>
<ParticipantInput participants={[]} onAdd={vi.fn()} onRemove={vi.fn()} />
</div>,
);
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'user' } });
await act(() => new Promise((r) => setTimeout(r, 300)));
const list = screen.getByRole('listbox');
expect(screen.getByTestId('form').contains(list)).toBe(false);
expect(list.parentElement).toBe(document.body);
});
});
+65 -50
View File
@@ -9,7 +9,7 @@ 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
@@ -180,26 +183,49 @@ export function CalendarAgendaView({
</div>
)}
{grouped.map((group, index) => (
<div key={group.dateKey} ref={index === focusIndex ? focusRowRef : undefined} data-agenda-day={group.dateKey}>
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border">
{grouped.map((group, index) => {
const today = isDisplayToday(group.date);
const relativeLabel = today
? t("events.today_header")
: isTomorrow(group.date) ? t("events.tomorrow_header") : null;
return (
<div
key={group.dateKey}
ref={index === focusIndex ? focusRowRef : undefined}
data-agenda-day={group.dateKey}
className="flex items-start gap-2 px-2 sm:px-4 py-1.5 border-b border-border"
aria-label={formatDateHeader(group.date)}
role="group"
>
{/* The date sits on the left of its events, as in a schedule. */}
<div className="sticky top-0 flex items-center gap-2 w-24 sm:w-32 flex-shrink-0 py-1.5">
<span className={cn(
"text-sm font-medium",
isDisplayToday(group.date) && "text-primary"
"inline-flex items-center justify-center w-9 h-9 rounded-full text-xl flex-shrink-0",
today ? "bg-primary text-primary-foreground font-semibold" : "text-foreground",
)}>
{formatDateHeader(group.date)}
{intlFormatter.dateTime(group.date, { day: "numeric" })}
</span>
<span className="text-xs text-muted-foreground ms-2">
{intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
<span className="min-w-0 leading-tight">
<span className={cn(
"block text-[11px] font-medium uppercase tracking-wide truncate",
today ? "text-primary" : "text-muted-foreground",
)}>
{intlFormatter.dateTime(group.date, { month: "short", weekday: "short" })}
</span>
{relativeLabel && (
<span className={cn("block text-[11px] truncate", today ? "text-primary" : "text-muted-foreground")}>
{relativeLabel}
</span>
)}
</span>
</div>
{group.events.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
<div className="flex-1 py-3.5 text-sm text-muted-foreground">
{t("events.no_events")}
</div>
) : (
<div className="divide-y divide-border">
<div className="flex-1 min-w-0 py-0.5">
{group.events.map((ev) => {
const calId = getPrimaryCalendarId(ev);
const calendar = calId ? calendarMap.get(calId) : undefined;
@@ -207,11 +233,13 @@ 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;
const meta = [locationName, calendar?.name].filter(Boolean).join(" · ");
return (
<button
@@ -220,49 +248,35 @@ 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"
)}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
className="w-full flex items-start gap-3 px-2 rounded-md hover:bg-muted/60 transition-colors text-start"
style={{ paddingBlock: 'var(--density-item-py)' }}
>
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
{ev.showWithoutTime ? (
<span className="text-xs font-medium text-muted-foreground">
{t("events.all_day")}
</span>
) : (
<>
<span className="text-sm font-medium">{formatTime(start)}</span>
<span className="text-xs text-muted-foreground">{formatTime(end)}</span>
</>
)}
</div>
<span className="w-28 sm:w-32 flex-shrink-0 text-sm text-muted-foreground tabular-nums">
{ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`}
</span>
<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 && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<MapPin className="w-3 h-3 flex-shrink-0" />
<span className="truncate">{locationName}</span>
</div>
)}
{getParticipantCount(ev) > 0 && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Users className="w-3 h-3 flex-shrink-0" />
<span>{getParticipantCount(ev)}</span>
</div>
)}
{calendar && (
<div className="text-xs text-muted-foreground mt-0.5">
{calendar.name}
{(meta || getParticipantCount(ev) > 0) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5 min-w-0">
{locationName && <MapPin className="w-3 h-3 flex-shrink-0" />}
{meta && <span className="truncate">{meta}</span>}
{getParticipantCount(ev) > 0 && (
<span className="flex items-center gap-1 flex-shrink-0">
<Users className="w-3 h-3" />
{getParticipantCount(ev)}
</span>
)}
</div>
)}
</div>
@@ -272,7 +286,8 @@ export function CalendarAgendaView({
</div>
)}
</div>
))}
);
})}
<div
ref={bottomSentinelRef}
+299 -96
View File
@@ -48,26 +48,30 @@ 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 { buildDuplicateEventData } from "@/lib/calendar-duplicate";
import { filterTasksByCalendars } from "@/lib/calendar-tasks";
import { baseEventStoreId, isServerRecurrenceInstance } from "@/lib/recurrence-instances";
import { deleteOccurrence, saveOccurrenceEdit, type EventEditorState } from "@/lib/recurrence-scope-edit";
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { getClientByLocalAccountId } from "@/stores/client-registry";
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
import { getEventStartDate } from "@/lib/calendar-utils";
import { displayNow } from "@/lib/timezone";
import { displayNow, getEffectiveTimeZone } from "@/lib/timezone";
import { canCreateEventsIn } from "@/lib/calendar-editability";
import { QuickCreatePopover, type QuickCreateDraft } from "@/components/calendar/quick-create-popover";
import { TASKS_LOAD_ERROR, 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 { cn, generateUUID } from "@/lib/utils";
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 { CalendarSidebarHeader } from "@/components/calendar/calendar-sidebar-header";
import { getUserParticipantId, collectUserCalendarAddresses } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
@@ -85,6 +89,10 @@ import {
type ScrollWindowState, type ScrollWindowViewProps,
} from "@/lib/calendar-scroll-window";
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { findMeetingLink } from "@/lib/event-links";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
type PendingScopeAction =
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; editor?: EventEditorState; sendScheduling?: boolean }
@@ -107,11 +115,15 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const t = useTranslations("calendar");
// The server's reason for a refused change, where the user can act on it.
const describeError = useCalendarErrorMessage();
const tSidebar = useTranslations("sidebar");
useDocumentTitle(tSidebar("calendar"));
const tWebcalAction = useTranslations("calendar.webcal_action");
const tDeepLink = useTranslations("deep_link");
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded();
// Mountain View's desktop chrome: its top bar in place of the left rail.
const mvShell = useMountainViewShell(isEmbedded);
// When the pane (Pro shell) or window is narrower than `lg`, the sidebar
// collapses into a burger-toggled overlay instead of taking inline space.
const isNarrow = !isDesktop;
@@ -136,6 +148,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";
@@ -186,7 +199,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
// A clicked event's card stays until closed; a hover preview goes when
// the pointer leaves. Held in a ref too for the hover timers.
const [detailPinned, setDetailPinned] = useState(false);
const detailPinnedRef = useRef(false);
useEffect(() => { detailPinnedRef.current = detailPinned; }, [detailPinned]);
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
// Quick create (#calendar redesign): an empty slot clicked in the week or
// day view opens a small editor beside a placeholder block.
const [quickDraft, setQuickDraft] = useState<QuickCreateDraft | null>(null);
const [defaultModalTitle, setDefaultModalTitle] = useState<string | undefined>();
const [modalStartsInEdit, setModalStartsInEdit] = useState(false);
const [showTaskModal, setShowTaskModal] = useState(false);
const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(null);
const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false);
@@ -200,6 +223,18 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
});
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
// The burger in the toolbar hides the sidebar on wide screens, like
// Google Calendar's main menu button; remembered across visits.
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
try { return localStorage.getItem("calendar-sidebar-collapsed") === "1"; } catch { return false; }
});
const toggleSidebarCollapsed = useCallback(() => {
setSidebarCollapsed((prev) => {
const next = !prev;
try { localStorage.setItem("calendar-sidebar-collapsed", next ? "1" : "0"); } catch { /* storage unavailable */ }
return next;
});
}, []);
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
@@ -564,6 +599,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
setNarrowSidebarOpen(false);
}, [setSelectedDate, jumpTo, isMobile, normalizedViewMode, setViewMode]);
// Clicking a date number in the week or month header opens that day in
// the day view, as in Google Calendar.
const handleOpenDay = useCallback((date: Date) => {
setMobileReturnToMonth(isMobile && normalizedViewMode === "month");
jumpTo(date);
setSelectedDate(date);
setMiniMonth(date);
setViewMode("day");
setNarrowSidebarOpen(false);
}, [jumpTo, setSelectedDate, setViewMode, isMobile, normalizedViewMode]);
// The mini calendar is a navigation control: unlike a click in the week or
// month grid, which marks a day that is already on screen, picking a day
// here has to bring that day into view.
@@ -586,30 +632,54 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}, [jumpTo]);
const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => {
setQuickDraft(null);
setDefaultModalTitle(undefined);
setEditEvent(null);
const d = date || selectedDate;
setDefaultModalDate(d);
setDefaultModalEndDate(endDate);
setDefaultModalAllDay(allDay ?? false);
setSelectedDate(d);
setShowTaskModal(false);
setEditTask(null);
setShowEventModal(true);
}, [selectedDate, setSelectedDate]);
const openEditModal = useCallback((event: CalendarEvent) => {
const openEditModal = useCallback((event: CalendarEvent, startInForm = false) => {
setModalStartsInEdit(startInForm);
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;
@@ -633,15 +703,27 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const closeDetail = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
detailPinnedRef.current = false;
setDetailPinned(false);
setDetailEvent(null);
setDetailAnchorRect(null);
}, []);
const handleSelectEvent = useCallback((event: CalendarEvent, _anchorRect: DOMRect) => {
// Click opens the sidebar for viewing/editing
closeDetail();
openEditModal(event);
}, [closeDetail, openEditModal]);
const handleSelectEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
// Phones open the full editor; wide screens show the event's card next
// to it, with edit and delete at the top (as in Google Calendar).
if (isMobile) {
closeDetail();
openEditModal(event);
return;
}
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
setQuickDraft(null);
detailPinnedRef.current = true;
setDetailPinned(true);
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, [isMobile, closeDetail, openEditModal]);
const {
contextMenu: eventContextMenu,
@@ -672,6 +754,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return;
if (detailPinnedRef.current) return;
if (calendarHoverPreview === 'off') return;
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
// Don't show hover popover if the sidebar is already open for this event
@@ -690,7 +773,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const handleHoverLeave = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
if (detailPinnedRef.current) return;
hoverTimerRef.current = setTimeout(() => {
if (detailPinnedRef.current) return;
setDetailEvent(null);
setDetailAnchorRect(null);
}, 300);
@@ -762,6 +847,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,
@@ -779,7 +865,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
if (detailEvent) {
const ev = detailEvent;
closeDetail();
openEditModal(ev);
// The pencil on the event card goes straight to the form.
openEditModal(ev, true);
}
}, [detailEvent, closeDetail, openEditModal]);
@@ -922,6 +1009,82 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
await save(requestedScheduling);
}, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, confirmAction, describeError, t]);
// Calendars a new event can go into: never a subscription or read-only one (#762).
const writableCalendars = useMemo(
() => calendars.filter((c) => !c.isTasksOnly && canCreateEventsIn(c, isSubscriptionCalendar)),
[calendars, isSubscriptionCalendar],
);
// A click or drag on empty grid space. On wide screens it opens the quick
// editor at that spot; on phones the full editor, as before.
const handleGridCreate = useCallback((start: Date, end?: Date) => {
if (isMobile) {
openCreateModal(start, end);
return;
}
const finalEnd = end ?? new Date(start.getTime() + 60 * 60 * 1000);
const preferred = writableCalendars.find((c) => c.isDefault) ?? writableCalendars[0];
setShowEventModal(false);
setEditEvent(null);
setShowTaskModal(false);
setEditTask(null);
closeDetail();
setQuickDraft((prev) => ({
start,
end: finalEnd,
title: "",
calendarId: prev && writableCalendars.some((c) => c.id === prev.calendarId)
? prev.calendarId
: preferred?.id ?? "",
}));
}, [isMobile, openCreateModal, writableCalendars, closeDetail]);
const closeQuickCreate = useCallback(() => setQuickDraft(null), []);
const handleQuickCreateMoreOptions = useCallback(() => {
if (!quickDraft) return;
const draft = quickDraft;
openCreateModal(draft.start, draft.end);
setDefaultCalendarIdForCreate(draft.calendarId || undefined);
setDefaultModalTitle(draft.title || undefined);
}, [quickDraft, openCreateModal]);
const handleQuickCreateSave = useCallback(async () => {
if (!quickDraft) return;
const { start, end, title, calendarId } = quickDraft;
const minutes = Math.max(15, Math.round((end.getTime() - start.getTime()) / 60000));
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
const duration = `PT${hours > 0 ? `${hours}H` : ""}${rest > 0 ? `${rest}M` : ""}`;
setQuickDraft(null);
// Grid dates are wall-clock times in the user's zone (#755), the same
// as the full editor's fields, so they are labelled with that zone.
await handleSaveEvent({
uid: generateUUID(),
title: title.trim(),
start: format(start, "yyyy-MM-dd'T'HH:mm:ss"),
duration,
timeZone: getEffectiveTimeZone(),
showWithoutTime: false,
calendarIds: { [calendarId]: true },
status: "confirmed",
freeBusyStatus: "busy",
privacy: "public",
});
}, [quickDraft, handleSaveEvent]);
// While the quick editor is open its placeholder takes the preview slot.
const gridPreview = useMemo<PendingEventPreview | null>(() => {
if (!quickDraft) return pendingPreview;
return {
start: quickDraft.start,
end: quickDraft.end,
title: quickDraft.title.trim() || t("events.no_title"),
allDay: false,
calendarId: quickDraft.calendarId,
};
}, [quickDraft, pendingPreview, t]);
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
try {
@@ -1048,24 +1211,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const handleDuplicateFromDetail = useCallback(async () => {
if (!detailEvent || !client) return;
const start = parseISO(detailEvent.start);
const newStart = addDays(start, 1);
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
title: detailEvent.title,
description: detailEvent.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
duration: detailEvent.duration,
timeZone: detailEvent.timeZone,
showWithoutTime: detailEvent.showWithoutTime,
calendarIds: { ...detailEvent.calendarIds },
status: "confirmed",
freeBusyStatus: detailEvent.freeBusyStatus,
privacy: detailEvent.privacy,
});
if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations);
if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules);
if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts);
if (detailEvent.participants) data.participants = structuredClone(detailEvent.participants);
const data = buildDuplicateEventData(detailEvent);
closeDetail();
try {
const created = await createEvent(client, data);
@@ -1096,24 +1242,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
const start = parseISO(event.start);
const newStart = addDays(start, 1);
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
title: event.title,
description: event.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
duration: event.duration,
timeZone: event.timeZone,
showWithoutTime: event.showWithoutTime,
calendarIds: { ...event.calendarIds },
status: "confirmed",
freeBusyStatus: event.freeBusyStatus,
privacy: event.privacy,
});
if (event.locations) data.locations = structuredClone(event.locations);
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
if (event.alerts) data.alerts = structuredClone(event.alerts);
if (event.participants) data.participants = structuredClone(event.participants);
const data = buildDuplicateEventData(event);
try {
const created = await createEvent(client, data);
if (created) {
@@ -1144,9 +1273,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}, [t]);
const handleCopyMeetingLink = useCallback(async (event: CalendarEvent) => {
const uri = event.virtualLocations
? Object.values(event.virtualLocations).find((v) => v.uri)?.uri
: undefined;
const uri = findMeetingLink(event)?.uri;
if (!uri) return;
try {
await navigator.clipboard.writeText(uri);
@@ -1255,6 +1382,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) {
@@ -1353,6 +1487,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
events={visibleEvents}
calendars={allCalendars}
onSelectDate={handleSelectDate}
onOpenDay={handleOpenDay}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
@@ -1362,6 +1497,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile}
pendingPreview={pendingPreview}
tasks={calendarTasks}
onToggleTaskComplete={handleToggleTaskComplete}
onSelectTask={openEditTaskModal}
currentUserEmails={currentUserEmails}
/>
);
@@ -1373,19 +1511,21 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
events={visibleEvents}
calendars={allCalendars}
onSelectDate={handleSelectDate}
onOpenDay={handleOpenDay}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onContextMenuEmpty={handleContextMenuEmpty}
onCreateAtTime={openCreateModal}
onCreateAtTime={handleGridCreate}
firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat}
isMobile={isMobile}
pendingPreview={pendingPreview}
pendingPreview={gridPreview}
tasks={calendarTasks}
onToggleTaskComplete={handleToggleTaskComplete}
onSelectTask={openEditTaskModal}
currentUserEmails={currentUserEmails}
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
/>
);
case "day":
@@ -1400,13 +1540,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onContextMenuEmpty={handleContextMenuEmpty}
onCreateAtTime={openCreateModal}
onCreateAtTime={handleGridCreate}
timeFormat={timeFormat}
isMobile={isMobile}
pendingPreview={pendingPreview}
pendingPreview={gridPreview}
tasks={calendarTasks}
onToggleTaskComplete={handleToggleTaskComplete}
onSelectTask={openEditTaskModal}
currentUserEmails={currentUserEmails}
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
/>
);
case "agenda":
@@ -1420,6 +1561,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
/>
);
case "tasks":
@@ -1442,7 +1584,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) {
@@ -1470,13 +1612,46 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
);
};
const calendarToolbar = (
<CalendarToolbar
selectedDate={selectedDate}
visibleDate={visibleDate}
viewMode={normalizedViewMode}
onPrev={navigatePrev}
onNext={navigateNext}
onToday={goToToday}
onViewModeChange={(mode) => { setMobileReturnToMonth(false); setViewMode(mode); }}
onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)}
onSubscribe={() => setShowSubscriptionModal(true)}
isMobile={isMobile}
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
enableCalendarTasks={enableCalendarTasks}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : isMobile ? undefined : toggleSidebarCollapsed}
sidebarOpen={isNarrow ? narrowSidebarOpen : !sidebarCollapsed}
showCreateButton={isNarrow || sidebarCollapsed}
/>
);
return (
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
{refreshIndicator}
<div className={cn("relative flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{mvShell && (
<MvTopBar
showMenuButton={false}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
<div data-mv-card={mvShell ? "" : undefined} className={cn("relative flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col", mvShell && "mb-4 mx-4")}>
{/* Left Navigation Rail (hidden when embedded in Pro shell) */}
{!isMobile && !isEmbedded && (
{!isMobile && !isEmbedded && !mvShell && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
@@ -1506,12 +1681,15 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
/>
)}
{/* Sidebar - in-flow when desktop pane, overlay when narrow */}
{!inlineApp && (
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<div className="flex flex-1 min-w-0 min-h-0">
{/* Sidebar - in-flow when desktop pane, overlay when narrow */}
{(isNarrow || !sidebarCollapsed) && (
<>
<div
className={cn(
"border-e border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
"border-e border-border bg-secondary flex flex-col overflow-hidden flex-shrink-0",
!isResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
@@ -1521,6 +1699,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
)}
style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }}
>
<CalendarSidebarHeader
onCreateEvent={() => { setNarrowSidebarOpen(false); openCreateModal(); }}
tourTarget={!isNarrow}
/>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3">
<div className="px-3">
<MiniCalendar
selectedDate={selectedDate}
displayMonth={miniMonth}
@@ -1530,6 +1714,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
firstDayOfWeek={firstDayOfWeek}
showWeekNumbers={showWeekNumbers}
/>
</div>
<CalendarSidebarPanel
calendars={allCalendars}
selectedCalendarIds={selectedCalendarIds}
@@ -1596,6 +1781,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
client={client}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/>
</div>
</div>
{!isNarrow && (
<ResizeHandle
@@ -1611,27 +1797,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
</>
)}
{!inlineApp && (
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<CalendarToolbar
selectedDate={selectedDate}
visibleDate={visibleDate}
viewMode={normalizedViewMode}
onPrev={navigatePrev}
onNext={navigateNext}
onToday={goToToday}
onViewModeChange={(mode) => { setMobileReturnToMonth(false); setViewMode(mode); }}
onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)}
onSubscribe={() => setShowSubscriptionModal(true)}
isMobile={isMobile}
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
enableCalendarTasks={enableCalendarTasks}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/>
{calendarToolbar}
{/* isolate keeps the views' sticky headers (z-50) below the toolbar's
import dropdown, which overlaps this area (#1049). */}
@@ -1656,12 +1823,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{!isMobile && showEventModal && (
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
<EventModal
key={editEvent?.id ?? 'new'}
key={`${editEvent?.id ?? 'new'}${modalStartsInEdit ? ':edit' : ''}`}
event={editEvent}
calendars={displayCalendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay}
defaultTitle={defaultModalTitle}
startInEditMode={modalStartsInEdit}
defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
@@ -1704,6 +1873,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
)}
</div>
</div>
</div>
</div>
)}
{/* Mobile Bottom Navigation */}
@@ -1763,15 +1934,24 @@ 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}
/>
);
})()}
{quickDraft && !isMobile && (
<QuickCreatePopover
draft={quickDraft}
calendars={writableCalendars}
timeFormat={timeFormat}
onChange={setQuickDraft}
onSave={handleQuickCreateSave}
onMoreOptions={handleQuickCreateMoreOptions}
onClose={closeQuickCreate}
/>
)}
{detailEvent && detailAnchorRect && (
<EventDetailPopover
event={detailEvent}
@@ -1780,6 +1960,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
onDuplicate={handleDuplicateFromDetail}
onMore={(e) => handleContextMenuEvent(e, detailEvent)}
onClose={closeDetail}
onSaveNote={handleSaveNoteFromDetail}
onRsvp={handleRsvpFromDetail}
@@ -1794,12 +1975,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{showEventModal && isMobile && (
<EventModal
key={editEvent?.id ?? 'new'}
key={`${editEvent?.id ?? 'new'}${modalStartsInEdit ? ':edit' : ''}`}
event={editEvent}
calendars={displayCalendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay}
defaultTitle={defaultModalTitle}
startInEditMode={modalStartsInEdit}
defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
@@ -1813,6 +1996,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}
+125 -60
View File
@@ -3,19 +3,24 @@
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 "@/components/icons";
import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { AllHoursToggle, HiddenEventsIndicator } from "./display-hours-controls";
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
import { clipToDisplayHours, partitionByDisplayHours } from "@/lib/calendar-display-range";
import { displayNow, isDisplayToday } from "@/lib/timezone";
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useDisplayHours } from "@/hooks/use-display-hours";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import type { PendingEventPreview } from "./event-modal";
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
import { readableTextOn } from "@/lib/color-transform";
interface CalendarDayViewProps extends ScrollWindowViewProps {
selectedDate: Date;
@@ -30,10 +35,14 @@ interface CalendarDayViewProps extends ScrollWindowViewProps {
timeFormat?: "12h" | "24h";
isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
/** The user's calendar addresses: moving an event they organize asks its attendees again. */
currentUserEmails?: string[];
tasks?: CalendarTask[];
onToggleTaskComplete?: (task: CalendarTask) => void;
onSelectTask?: (task: CalendarTask) => void;
/**
* The user's calendar addresses, to mark events they declined (#1110).
* Moving an event they organize also asks its attendees again.
*/
currentUserEmails?: string[];
}
const HOUR_HEIGHT = 64;
@@ -61,9 +70,10 @@ export function CalendarDayView({
timeFormat = "24h",
isMobile,
pendingPreview,
currentUserEmails,
tasks,
onToggleTaskComplete,
onSelectTask,
currentUserEmails,
}: CalendarDayViewProps) {
const t = useTranslations("calendar");
const describeError = useCalendarErrorMessage();
@@ -147,31 +157,26 @@ 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]);
const {
hours, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes,
} = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT });
const firstHour = hours.startMinutes / 60;
const visibleHours = HOURS.slice(firstHour, hours.endMinutes / 60);
const gridHeight = visibleHours.length * HOUR_HEIGHT;
// Column layouts are the costly part of a render; with months of columns
// they must not be redone on every scroll-driven re-render.
const layoutByDay = useMemo(() => {
const map = new Map<string, ReturnType<typeof layoutOverlappingEvents>>();
const map = new Map<string, ReturnType<typeof partitionByDisplayHours> & { layouts: ReturnType<typeof layoutOverlappingEvents> }>();
for (const day of days) {
const key = format(day, "yyyy-MM-dd");
map.set(key, layoutOverlappingEvents(eventsByDay.get(key)?.timed ?? [], day));
const partition = partitionByDisplayHours(eventsByDay.get(key)?.timed ?? [], day, hours);
map.set(key, { ...partition, layouts: layoutOverlappingEvents(partition.visible, day) });
}
return map;
}, [days, eventsByDay]);
}, [days, eventsByDay, hours]);
const hasAllDayArea = useMemo(
() => days.some((day) => {
@@ -181,13 +186,6 @@ export function CalendarDayView({
[days, eventsByDay, tasksByDay],
);
useEffect(() => {
if (rootRef.current) {
const now = displayNow();
rootRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
}
}, []);
const scrollToFocus = useCallback(() => {
const root = rootRef.current;
if (!root) return;
@@ -265,6 +263,8 @@ export function CalendarDayView({
dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop,
} = useTimeGridInteractions({
hourHeight: HOUR_HEIGHT,
gridStartMinutes: hours.startMinutes,
gridEndMinutes: hours.endMinutes,
calendars,
onCreateRange: onCreateAtTime,
errorMessages: {
@@ -303,7 +303,16 @@ export function CalendarDayView({
<div ref={endSentinelRef} data-testid="day-end-sentinel" className="absolute inset-y-0 end-0 w-px pointer-events-none" />
<div className="sticky top-0 z-50 flex border-b border-border bg-background">
<div className={gutterClass} />
<div className={cn(gutterClass, "flex items-end justify-center pb-1")}>
{canToggleHours && (
<AllHoursToggle
showAllHours={showAllHours}
configured={configuredHours}
timeFormat={timeFormat}
onToggle={toggleAllHours}
/>
)}
</div>
<div className="grid" style={columnsStyle}>
{days.map((day) => {
const key = format(day, "yyyy-MM-dd");
@@ -312,13 +321,26 @@ export function CalendarDayView({
const dayTasks = tasksByDay.get(key) ?? [];
return (
<div key={key} data-day={key} className="min-w-0 border-e border-border last:border-e-0">
<div className={cn("px-4 py-3", isMobile && "px-3 py-2")}>
<h3 className={cn("font-semibold truncate", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
{isMobile
? intlFormatter.dateTime(day, { weekday: "short", month: "short", day: "numeric" })
: intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" })
}
</h3>
<div
className={cn("flex flex-col items-start gap-0.5 px-3 pt-2 pb-1.5", isMobile && "px-2")}
role="columnheader"
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
>
<div className={cn(
"w-10 text-center text-[11px] font-medium uppercase tracking-wide",
today ? "text-primary" : "text-muted-foreground",
)}>
{intlFormatter.dateTime(day, { weekday: "short" })}
</div>
<div
aria-current={today ? "date" : undefined}
className={cn(
"inline-flex items-center justify-center w-10 h-10 rounded-full text-xl",
today ? "bg-primary text-primary-foreground font-semibold" : "text-foreground",
)}
>
{format(day, "d")}
</div>
</div>
{hasAllDayArea && (
@@ -345,6 +367,7 @@ export function CalendarDayView({
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
/>
);
})}
@@ -362,10 +385,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",
@@ -373,6 +399,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>
@@ -394,16 +421,22 @@ export function CalendarDayView({
</div>
<div>
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className="flex relative" style={{ height: gridHeight }}>
<div className={gutterClass}>
{HOURS.map((h) => (
{visibleHours.map((h) => (
<div
key={h}
className="relative text-muted-foreground text-end pe-2"
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
<span className={cn("absolute top-0 right-2 -translate-y-1/2 leading-none", isMobile ? "text-[10px]" : "text-xs")}>
{/* Midnight goes unlabelled; a later first hour is labelled
inside its row, where the header cannot cover it. */}
{(h > firstHour || firstHour > 0) && (
<span className={cn(
"absolute right-2 leading-none",
h > firstHour ? "top-0 -translate-y-1/2" : "top-1",
isMobile ? "text-[10px]" : "text-xs",
)}>
{formatHour(h)}
</span>
)}
@@ -415,7 +448,8 @@ export function CalendarDayView({
{days.map((day) => {
const key = format(day, "yyyy-MM-dd");
const today = isDisplayToday(day);
const layouted = layoutByDay.get(key) ?? [];
const dayLayout = layoutByDay.get(key);
const layouted = dayLayout?.layouts ?? [];
return (
<div
@@ -430,7 +464,7 @@ export function CalendarDayView({
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, day)}
>
{HOURS.map((h) => (
{visibleHours.map((h) => (
<div
key={h}
role="gridcell"
@@ -443,10 +477,30 @@ export function CalendarDayView({
/>
))}
{dayLayout && dayLayout.before > 0 && (
<HiddenEventsIndicator
count={dayLayout.before}
direction="before"
onReveal={() => revealMinutes(dayLayout.firstBeforeMinutes ?? 0)}
/>
)}
{dayLayout && dayLayout.after > 0 && (
<HiddenEventsIndicator
count={dayLayout.after}
direction="after"
onReveal={() => revealMinutes(dayLayout.firstAfterMinutes ?? hours.endMinutes)}
/>
)}
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
const clip = clipToDisplayHours(startMinutes, endMinutes, hours);
const baseHeight = Math.max(24, (Math.max(15, clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT);
// Short events at the bottom edge stay inside the grid.
const baseTop = Math.max(0, Math.min(
((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
gridHeight - baseHeight,
));
const isResizing = resizeVisual?.eventId === ev.id;
const top = isResizing ? resizeVisual!.topPx : baseTop;
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
@@ -469,8 +523,11 @@ export function CalendarDayView({
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
draggable
/>
{/* An edge beyond the visible hours has nothing to grab. */}
{!clip.clippedStart && (
<div
data-resize-handle
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
@@ -481,6 +538,8 @@ export function CalendarDayView({
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
</div>
)}
{!clip.clippedEnd && (
<div
data-resize-handle
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
@@ -491,18 +550,19 @@ export function CalendarDayView({
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mb-0.5" />
</div>
)}
</div>
);
})}
{today && (
{today && nowMinutes >= hours.startMinutes && nowMinutes <= hours.endMinutes && (
<div
className="absolute left-0 right-0 z-20 pointer-events-none"
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
style={{ top: ((nowMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ms-1" />
<div className="flex-1 h-px bg-destructive" />
<div className="flex items-center -translate-y-1/2">
<div className="w-3 h-3 rounded-full bg-destructive -ms-1.5 flex-shrink-0" />
<div className="flex-1 h-0.5 bg-destructive" />
</div>
</div>
)}
@@ -519,7 +579,7 @@ export function CalendarDayView({
<div
className="absolute left-1 right-1 z-30 rounded-md pointer-events-none bg-primary/15 border-2 border-primary/30 border-dashed"
style={{
top: (dragCreate.startMinutes / 60) * HOUR_HEIGHT,
top: ((dragCreate.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
height: ((dragCreate.endMinutes - dragCreate.startMinutes) / 60) * HOUR_HEIGHT,
}}
>
@@ -532,7 +592,7 @@ export function CalendarDayView({
{dropTarget?.dayKey === key && (
<div
className="absolute left-0 right-0 z-30 pointer-events-none"
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
style={{ top: ((dropTarget.minutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-primary -ms-1" />
@@ -549,22 +609,27 @@ export function CalendarDayView({
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
const durationMin = Math.max(15, endMin - startMin);
if (startMin + durationMin <= hours.startMinutes || startMin >= hours.endMinutes) return null;
const clip = clipToDisplayHours(startMin, startMin + durationMin, hours);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
const color = cal?.color || "#3b82f6";
// Drawn like a saved event, so the new event shows where
// it will land while it is being created.
return (
<div
className="absolute left-2 right-2 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
data-pending-preview
className="absolute left-0.5 right-0.5 z-30 rounded-xs pointer-events-none overflow-hidden px-1.5 py-0.5 text-xs shadow-md"
style={{
top: (startMin / 60) * HOUR_HEIGHT,
height: Math.max(24, (durationMin / 60) * HOUR_HEIGHT),
borderColor: color,
backgroundColor: `${color}10`,
top: ((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
height: Math.max(24, ((clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT),
backgroundColor: color,
color: readableTextOn(color),
}}
>
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
<div className="font-medium truncate">
{pendingPreview.title}
</div>
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
<div className="text-[10.5px] opacity-85 truncate">
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
</div>
</div>
+153 -19
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 { movedEventUpdates } from "@/lib/calendar-participants";
@@ -29,10 +32,18 @@ interface CalendarMonthViewProps extends ScrollWindowViewProps {
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime?: (date: Date) => void;
/** Open a day in the day view (its date number, or its "N more" link). */
onOpenDay?: (date: Date) => void;
firstDayOfWeek?: number;
isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
/** The user's calendar addresses: moving an event they organize asks its attendees again. */
tasks?: CalendarTask[];
onToggleTaskComplete?: (task: CalendarTask) => void;
onSelectTask?: (task: CalendarTask) => void;
/**
* The user's calendar addresses, to mark events they declined (#1110).
* Moving an event they organize also asks its attendees again.
*/
currentUserEmails?: string[];
}
@@ -58,8 +69,12 @@ export function CalendarMonthView({
onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime,
onOpenDay,
isMobile,
pendingPreview,
tasks,
onToggleTaskComplete,
onSelectTask,
currentUserEmails,
}: CalendarMonthViewProps) {
const t = useTranslations("calendar");
@@ -121,13 +136,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);
@@ -145,6 +175,11 @@ export function CalendarMonthView({
return () => observer.disconnect();
}, []);
const rowMinHeight = Math.max(baseRowMinHeight, Math.floor(viewportHeight / 6));
// On wide screens a week keeps its height: a day with more entries than
// fit shows the first ones and a "N more" link (as in Google Calendar).
// Phones keep growing the row to fit.
const rowCapacity = Math.max(1, Math.floor((rowMinHeight - overlayTop - 4 - 8) / rowHeight));
const capRows = showChips && !isMobile;
// The month the user is looking at: sampled a little above the middle of
// the viewport. It dims the other months' days and drives the title.
@@ -234,7 +269,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;
@@ -264,10 +301,25 @@ 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 }) => {
const overflowing = capRows && contentRows > rowCapacity;
// Rows drawn per day; the last one is taken by the "N more" link.
const rowLimit = overflowing ? rowCapacity - 1 : Infinity;
const hiddenPerDay = overflowing
? week.map((_, dayIndex) => {
const hiddenEvents = segments.filter((segment) =>
segment.row >= rowLimit
&& dayIndex >= segment.startIndex
&& dayIndex < segment.startIndex + segment.span,
).length;
const hiddenTasks = dayTasks[dayIndex].filter((_, taskIndex) => dayEventRows[dayIndex] + taskIndex >= rowLimit).length;
return hiddenEvents + hiddenTasks;
})
: null;
return (
<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)
minHeight: showChips && !overflowing
? Math.max(rowMinHeight, overlayTop + 4 + contentRows * rowHeight + 8)
: rowMinHeight,
}}>
<div className="grid grid-cols-7 h-full">
@@ -277,6 +329,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);
@@ -307,18 +360,31 @@ export function CalendarMonthView({
{t(`months.${monthLabelKeys[getMonth(day)]}`)}
</span>
)}
<span
{/* The date opens that day in the day view; on phones the
whole cell already does. */}
<button
type="button"
tabIndex={isMobile ? -1 : undefined}
onClick={(e) => {
if (isMobile || !onOpenDay) return;
e.stopPropagation();
onOpenDay(day);
}}
onDoubleClick={(e) => e.stopPropagation()}
aria-label={fullDateLabel}
aria-current={today ? "date" : undefined}
className={cn(
"inline-flex items-center justify-center rounded-full",
"inline-flex items-center justify-center rounded-full transition-colors",
isMobile ? "w-7 h-7 text-xs" : "w-6 h-6 text-xs",
today && !selected && "bg-primary text-primary-foreground font-bold",
selected && "bg-primary text-primary-foreground font-bold",
today && "bg-primary text-primary-foreground font-bold",
selected && !today && "bg-primary/15 text-primary font-bold",
!inMonth && !selected && !today && "text-muted-foreground/50",
inMonth && !selected && !today && "font-medium"
inMonth && !selected && !today && "font-medium",
!today && !isMobile && "hover:bg-muted"
)}
>
{formatDayNumber(day)}
</span>
</button>
</div>
{isMobile && !showChips ? (
<div className="flex items-center justify-center gap-0.5 flex-wrap">
@@ -326,15 +392,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) && (
@@ -353,7 +432,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 (
@@ -383,7 +462,7 @@ export function CalendarMonthView({
{showChips && segments.length > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
{segments.map((segment) => {
{segments.filter((segment) => segment.row < rowLimit).map((segment) => {
const calId = getPrimaryCalendarId(segment.event);
return (
<div
@@ -406,6 +485,7 @@ export function CalendarMonthView({
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
draggable
className={isMobile ? "text-[10px] px-1" : undefined}
/>
@@ -414,8 +494,62 @@ 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) => {
if (dayEventRows[dayIndex] + taskIndex >= rowLimit) return null;
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>
)}
{hiddenPerDay && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
{hiddenPerDay.map((hidden, dayIndex) => hidden > 0 && (
<div
key={`more-${dayIndex}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(dayIndex / 7) * 100}% + 1px)`,
width: `calc(${(1 / 7) * 100}% - 2px)`,
top: rowLimit * rowHeight,
height: chipHeight,
}}
>
<button
type="button"
onClick={(e) => { e.stopPropagation(); (onOpenDay ?? onSelectDate)(week[dayIndex]); }}
className="w-full h-full text-start px-1.5 rounded-xs text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate"
>
{t("events.more", { count: hidden })}
</button>
</div>
))}
</div>
)}
</div>
))}
);
})}
<div ref={bottomSentinelRef} data-testid="month-bottom-sentinel" className="h-px flex-shrink-0" />
</div>
</div>
@@ -0,0 +1,39 @@
"use client";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Plus } from "@/components/icons";
interface CalendarSidebarHeaderProps {
onCreateEvent: () => void;
/** Mark the button for the guided tour; off while the toolbar's own "+" is the visible one. */
tourTarget?: boolean;
}
/**
* Title row at the top of the calendar sidebar, the same shape as the
* contacts sidebar header: the app name on the left and "+" on the right.
*/
export function CalendarSidebarHeader({ onCreateEvent, tourTarget = true }: CalendarSidebarHeaderProps) {
const t = useTranslations("calendar");
return (
<div
className="px-3 border-b border-border flex items-center justify-between flex-shrink-0"
style={{ paddingBlock: "var(--density-header-py)" }}
>
<span className="text-sm font-semibold truncate">{t("title")}</span>
<Button
size="icon"
variant="ghost"
onClick={onCreateEvent}
className="h-8 w-8 flex-shrink-0"
aria-label={t("events.create")}
title={t("events.create")}
data-tour={tourTarget ? "create-event-button" : undefined}
>
<Plus className="w-4 h-4" />
</Button>
</div>
);
}
+106 -44
View File
@@ -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 "@/components/icons";
import { Check, ChevronDown, ChevronRight, Globe, ListTodo, MoreVertical, 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";
@@ -17,6 +17,7 @@ import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu
import { useContextMenu } from "@/hooks/use-context-menu";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { displayNow } from "@/lib/timezone";
import { readableTextOn } from "@/lib/color-transform";
/**
* Split a per-account calendar list into "owned" (the user's own) and
@@ -243,26 +244,34 @@ export function CalendarSidebarPanel({
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
return (
<div key={cal.id} className="relative">
<div key={cal.id} className="relative group/cal">
<button
onClick={() => onToggleVisibility(cal.id)}
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
role="checkbox"
aria-checked={isVisible}
data-testid="calendar-item"
data-calendar-name={cal.name}
data-account={cal.accountName ?? ''}
data-visible={isVisible}
className={cn(
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted"
"flex items-center gap-2 w-full ps-5 pe-3 text-sm text-foreground/80 transition-colors duration-150",
"hover:bg-muted",
hasMenu && "pe-9"
)}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
>
{/* A checkbox filled in the calendar colour, empty when hidden. */}
<span
className={cn(
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 flex items-center justify-center transition-colors",
!isVisible && "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
style={isVisible ? { backgroundColor: color, borderColor: color, color: readableTextOn(color) } : undefined}
aria-hidden="true"
>
{isVisible && <Check className="w-2.5 h-2.5" stroke={3.5} />}
</span>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
@@ -284,6 +293,21 @@ export function CalendarSidebarPanel({
/>
)}
</button>
{hasMenu && (
<button
type="button"
onClick={(e) => openContextMenu(e, cal)}
className={cn(
"absolute end-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-opacity",
"opacity-0 group-hover/cal:opacity-100 focus-visible:opacity-100",
contextMenu.isOpen && contextMenu.data?.id === cal.id && "opacity-100"
)}
aria-label={t('calendar_options', { name: cal.name })}
title={t('calendar_options', { name: cal.name })}
>
<MoreVertical className="w-3.5 h-3.5" />
</button>
)}
</div>
);
};
@@ -396,12 +420,58 @@ export function CalendarSidebarPanel({
);
};
// "My calendars" / "Other calendars" fold away like the account groups,
// and share their remembered state.
const renderSectionHeader = (key: string, label: string, onAdd?: () => void) => {
const expanded = !collapsedAccountGroups.has(key);
return (
<div className="group flex items-center gap-1 pe-3">
<button
type="button"
onClick={() => toggleAccountGroup(key)}
aria-expanded={expanded}
className="flex-1 min-w-0 flex items-center gap-1 ps-3 py-1 text-start"
>
{expanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0 rtl:rotate-180" />
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">{label}</span>
</button>
{onAdd && (
<button
type="button"
onClick={onAdd}
className="p-0.5 rounded text-muted-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:text-foreground hover:bg-muted transition-opacity"
title={tMgmt('add_calendar')}
aria-label={tMgmt('add_calendar')}
>
<Plus className="w-3.5 h-3.5" />
</button>
)}
</div>
);
};
const renderSection = (key: string, label: string, list: Calendar[], onAdd?: () => void) => (
<div>
{renderSectionHeader(key, label, onAdd)}
{!collapsedAccountGroups.has(key) && (
<div>
{list.map(renderCalendarItem)}
</div>
)}
</div>
);
return (
<div className="mt-4">
{enableCalendarTasks && (
<button
onClick={() => setViewMode('tasks')}
className="flex items-center gap-2 w-full px-1.5 py-1.5 mb-3 rounded-md text-sm hover:bg-muted transition-colors"
className="flex items-center gap-2 w-full px-3 mb-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
>
<ListTodo className="w-4 h-4 text-muted-foreground" />
<span>{t('tasks.label')}</span>
@@ -423,7 +493,7 @@ export function CalendarSidebarPanel({
<div key={group.key} className={cn(idx === 0 ? "" : "mt-3")}>
<button
onClick={() => toggleAccountGroup(group.key)}
className="group w-full flex items-center gap-1.5 px-1 py-1 rounded-sm hover:bg-muted/40 transition-colors"
className="group w-full flex items-center gap-1.5 px-3 py-1 hover:bg-muted/40 transition-colors"
>
{expanded ? (
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
@@ -454,24 +524,24 @@ export function CalendarSidebarPanel({
)}
</button>
{expanded && (
<div className="mt-1 ps-3">
<div className="mt-1">
{owned.length > 0 && (
<div>
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
<div className="ps-5 pe-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
{t('my_calendars')}
</div>
<div className="space-y-0.5">
<div>
{owned.map(renderCalendarItem)}
</div>
</div>
)}
{sharedGroups.map((sg) => (
<div key={`${group.key}-shared-${sg.label}`} className="mt-2">
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
<div className="ps-5 pe-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
<Share2 className="w-3 h-3" />
{sg.label}
</div>
<div className="space-y-0.5">
<div>
{sg.calendars.map(renderCalendarItem)}
</div>
</div>
@@ -484,37 +554,29 @@ export function CalendarSidebarPanel({
</>
) : (
<>
<div className="flex items-center justify-between mb-2 px-1 group">
{onCreateCalendar ? (
<button
onClick={onCreateCalendar}
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
title={tMgmt('add_calendar')}
>
{t('my_calendars')}
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
) : (
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t('my_calendars')}
</h3>
)}
</div>
<div className="space-y-0.5">
{personalCalendars.map(renderCalendarItem)}
</div>
{renderSection(
'__mine__',
t('my_calendars'),
personalCalendars,
onCreateCalendar,
)}
{sharedAccountGroups.map((group) => (
<div key={group.accountName} className="mt-4">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{group.accountName}
</h3>
<div className="space-y-0.5">
{group.calendars.map(renderCalendarItem)}
</div>
{sharedAccountGroups.length > 0 && (
<div className="mt-2">
{renderSectionHeader('__others__', t('other_calendars'))}
{!collapsedAccountGroups.has('__others__') && sharedAccountGroups.map((group) => (
<div key={group.accountName} className="mt-2">
<div className="ps-5 pe-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
<Share2 className="w-3 h-3" />
{group.accountName}
</div>
<div>
{group.calendars.map(renderCalendarItem)}
</div>
</div>
))}
</div>
))}
)}
</>
)}
+309 -207
View File
@@ -3,12 +3,13 @@
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 "@/components/icons";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical } from "@/components/icons";
import { startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
import type { Calendar } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { displayNow } from "@/lib/timezone";
interface CalendarToolbarProps {
selectedDate: Date;
@@ -29,10 +30,26 @@ interface CalendarToolbarProps {
selectedCalendarIds?: string[];
onToggleVisibility?: (id: string) => void;
enableCalendarTasks?: boolean;
/** Show a burger button at the start that opens the (overlay) sidebar. */
/** Show a burger button at the start that shows or hides the sidebar. */
onMenuClick?: () => void;
/** Whether the sidebar is currently shown (for the burger's aria-expanded). */
sidebarOpen?: boolean;
/**
* Show a compact create button in the bar. The full "Create" button lives
* at the top of the sidebar, so this is only needed while it is hidden.
*/
showCreateButton?: boolean;
}
// Shortcut keys handled by the calendar app (see its keydown handler).
const VIEW_SHORTCUTS: Record<CalendarViewMode, string> = {
day: "D",
week: "W",
month: "M",
agenda: "A",
tasks: "K",
};
export function CalendarToolbar({
selectedDate,
visibleDate,
@@ -52,6 +69,8 @@ export function CalendarToolbar({
onToggleVisibility,
enableCalendarTasks,
onMenuClick,
sidebarOpen,
showCreateButton,
}: CalendarToolbarProps) {
const t = useTranslations("calendar");
const {
@@ -65,6 +84,12 @@ export function CalendarToolbar({
const views: CalendarViewMode[] = enableCalendarTasks
? ["month", "week", "day", "agenda", "tasks"]
: ["month", "week", "day", "agenda"];
// Desktop view menu, in the order Google Calendar lists them.
const menuViews: CalendarViewMode[] = enableCalendarTasks
? ["day", "week", "month", "agenda", "tasks"]
: ["day", "week", "month", "agenda"];
const [showViewMenu, setShowViewMenu] = useState(false);
const viewMenuRef = useRef<HTMLDivElement>(null);
const [showCalendarDropdown, setShowCalendarDropdown] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -124,228 +149,273 @@ export function CalendarToolbar({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showImportDropdown]);
useEffect(() => {
if (!showViewMenu && !showImportDropdown) return;
function handleClickOutside(e: MouseEvent) {
if (viewMenuRef.current && !viewMenuRef.current.contains(e.target as Node)) {
setShowViewMenu(false);
}
}
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setShowViewMenu(false);
setShowImportDropdown(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKey);
};
}, [showViewMenu, showImportDropdown]);
if (isMobile) {
return (
<div className="border-b border-border">
{/* ── MOBILE TOOLBAR ── */}
{isMobile && (
<div className="flex flex-col gap-1 px-2 py-2">
{/* Row 1: Back / Date nav / Today */}
<div className="flex items-center gap-1">
{onMenuClick && (
<button
onClick={onMenuClick}
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
</button>
)}
{onNavigateBack && (
<button
onClick={onNavigateBack}
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("back_to_month")}
>
<ArrowLeft className="w-4 h-4" />
</button>
)}
<button onClick={onPrev} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-semibold text-center flex-1 select-none truncate">
{getDateLabel()}
</span>
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ms-0.5">
{t("views.today")}
</Button>
</div>
{/* Row 2: View switcher pills + calendar toggle */}
<div className="flex items-center gap-1.5">
<div className="flex flex-1 border border-border rounded-md overflow-hidden">
{views.map((v) => (
<button
key={v}
onClick={() => onViewModeChange(v)}
className={cn(
"flex-1 py-1.5 text-[11px] font-medium transition-colors touch-manipulation",
v === viewMode
? "bg-primary text-primary-foreground"
: "text-muted-foreground active:bg-muted"
)}
>
{t(`views.${v}`)}
</button>
))}
</div>
{calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowCalendarDropdown((v) => !v)}
className={cn(
"p-1.5 rounded-md border border-border transition-colors touch-manipulation",
showCalendarDropdown ? "bg-muted" : "hover:bg-muted"
)}
aria-label={t("my_calendars")}
>
<CalendarDays className="w-4 h-4" />
</button>
{showCalendarDropdown && (
<div className="absolute top-full end-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
<div className="space-y-0.5">
{calendars.filter(c => !c.isShared).map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
);
})}
</div>
{(() => {
const shared = calendars.filter(c => c.isShared);
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
for (const c of shared) {
const key = c.accountId || c.accountName || c.id;
if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] });
groups.get(key)!.cals.push(c);
}
return Array.from(groups.values()).map((group) => (
<div key={group.accountName} className="mt-2">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
{group.accountName}
</h3>
<div className="space-y-0.5">
{group.cals.map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
);
})}
</div>
</div>
));
})()}
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
);
}
const currentViewLabel = t(`views.${viewMode}`);
return (
<div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
{/* Burger menu (rendered in pages that use a narrow overlay sidebar) */}
{onMenuClick && !isMobile && (
<div className="flex items-center gap-1 px-4 border-b border-border" style={{ paddingBlock: "var(--density-header-py)" }}>
{onMenuClick && (
<Button
variant="ghost"
size="icon"
onClick={onMenuClick}
className="h-8 w-8 -ms-1 me-1"
aria-label={t("nav_open_menu")}
aria-expanded={sidebarOpen}
>
<Menu className="w-4 h-4" />
</Button>
)}
{/* ── MOBILE TOOLBAR ── */}
{isMobile && (
<div className="flex flex-col gap-1 px-2 py-2">
{/* Row 1: Back / Date nav / Today */}
<div className="flex items-center gap-1">
{onMenuClick && (
<button
onClick={onMenuClick}
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
</button>
)}
{onNavigateBack && (
<button
onClick={onNavigateBack}
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("back_to_month")}
>
<ArrowLeft className="w-4 h-4" />
</button>
)}
<button onClick={onPrev} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-semibold text-center flex-1 select-none truncate">
{getDateLabel()}
</span>
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ms-0.5">
{t("views.today")}
</Button>
</div>
{/* Row 2: View switcher pills + calendar toggle */}
<div className="flex items-center gap-1.5">
<div className="flex flex-1 border border-border rounded-md overflow-hidden">
{views.map((v) => (
<button
key={v}
onClick={() => onViewModeChange(v)}
className={cn(
"flex-1 py-1.5 text-[11px] font-medium transition-colors touch-manipulation",
v === viewMode
? "bg-primary text-primary-foreground"
: "text-muted-foreground active:bg-muted"
)}
>
{t(`views.${v}`)}
</button>
))}
</div>
{calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowCalendarDropdown((v) => !v)}
className={cn(
"p-1.5 rounded-md border border-border transition-colors touch-manipulation",
showCalendarDropdown ? "bg-muted" : "hover:bg-muted"
)}
aria-label={t("my_calendars")}
>
<CalendarDays className="w-4 h-4" />
</button>
{showCalendarDropdown && (
<div className="absolute top-full end-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
<div className="space-y-0.5">
{calendars.filter(c => !c.isShared).map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
);
})}
</div>
{(() => {
const shared = calendars.filter(c => c.isShared);
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
for (const c of shared) {
const key = c.accountId || c.accountName || c.id;
if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] });
groups.get(key)!.cals.push(c);
}
return Array.from(groups.values()).map((group) => (
<div key={group.accountName} className="mt-2">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
{group.accountName}
</h3>
<div className="space-y-0.5">
{group.cals.map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
);
})}
</div>
</div>
));
})()}
</div>
)}
</div>
)}
</div>
</div>
{showCreateButton && (
<Button
variant="ghost"
size="icon"
onClick={onCreateEvent}
className="h-8 w-8"
aria-label={t("events.create")}
title={t("events.create")}
data-tour="create-event-button"
>
<Plus className="w-4 h-4" />
</Button>
)}
{/* ── DESKTOP TOOLBAR ── */}
{!isMobile && (
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={onToday} className="h-8 me-1">
{t("views.today")}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</Button>
<span className="text-base font-semibold ms-2 select-none">
{getDateLabel()}
</span>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={onToday}
className="h-8 me-1"
title={formatFullDate(displayNow())}
>
{t("views.today")}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onPrev}
aria-label={t("nav_prev")}
>
<ChevronLeft className="w-4 h-4 rtl:rotate-180" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onNext}
aria-label={t("nav_next")}
>
<ChevronRight className="w-4 h-4 rtl:rotate-180" />
</Button>
<h1 className="ms-2 text-base font-semibold truncate select-none">
{getDateLabel()}
</h1>
<div className="flex-1" />
{!isMobile && (
<div className="flex h-8 border border-border rounded-md overflow-hidden">
{views.map((v) => (
<button
key={v}
onClick={() => onViewModeChange(v)}
title={t(`views.${v}_hint`)}
className={cn(
"inline-flex items-center px-3 text-xs font-medium transition-colors",
v === viewMode
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground"
)}
>
{t(`views.${v}`)}
</button>
))}
</div>
)}
{(onImport || onSubscribe) && !isMobile && (
{(onImport || onSubscribe) && (
<div className="relative" ref={importDropdownRef}>
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
<Upload className="w-4 h-4 me-1" />
{t("import.title")}
<ChevronDown className="w-3 h-3 ms-1" />
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8",
showImportDropdown && "bg-accent text-accent-foreground",
)}
onClick={() => setShowImportDropdown((v) => !v)}
aria-label={t("toolbar.more")}
title={t("toolbar.more")}
aria-haspopup="menu"
aria-expanded={showImportDropdown}
>
<MoreVertical className="w-4 h-4" />
</Button>
{showImportDropdown && (
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
<div
role="menu"
className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]"
>
{onImport && (
<button
role="menuitem"
onClick={() => { onImport(); setShowImportDropdown(false); }}
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors text-foreground"
>
@@ -355,6 +425,7 @@ export function CalendarToolbar({
)}
{onSubscribe && (
<button
role="menuitem"
onClick={() => { onSubscribe(); setShowImportDropdown(false); }}
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors text-foreground"
>
@@ -367,12 +438,43 @@ export function CalendarToolbar({
</div>
)}
{!isMobile && (
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
<Plus className="w-4 h-4 me-1" />
{t("events.create")}
<div className="relative ms-1" ref={viewMenuRef}>
<Button
variant="outline"
size="sm"
className="h-8 gap-1"
onClick={() => setShowViewMenu((v) => !v)}
aria-haspopup="menu"
aria-expanded={showViewMenu}
aria-label={t("toolbar.select_view", { view: currentViewLabel })}
data-testid="calendar-view-menu"
>
{currentViewLabel}
<ChevronDown className="w-3 h-3" />
</Button>
)}
{showViewMenu && (
<div
role="menu"
className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]"
>
{menuViews.map((v) => (
<button
key={v}
role="menuitemradio"
aria-checked={v === viewMode}
onClick={() => { onViewModeChange(v); setShowViewMenu(false); }}
className={cn(
"flex items-center justify-between gap-6 w-full px-3 py-2 rounded-md text-sm transition-colors",
v === viewMode ? "bg-accent text-accent-foreground font-medium" : "hover:bg-muted text-foreground",
)}
>
<span>{t(`views.${v}`)}</span>
<kbd className="font-sans text-xs text-muted-foreground">{VIEW_SHORTCUTS[v]}</kbd>
</button>
))}
</div>
)}
</div>
</div>
);
}
+283 -161
View File
@@ -4,26 +4,34 @@ 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,
} from "date-fns";
import { cn } from "@/lib/utils";
import { Check } from "@/components/icons";
import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { CalendarTaskChip } from "./task-chip";
import { AllHoursToggle, HiddenEventsIndicator } from "./display-hours-controls";
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils";
import { clipToDisplayHours, indexOfDayOnOrAfter, partitionByDisplayHours, remapSegmentsToShownDays, resolveWorkingDays } from "@/lib/calendar-display-range";
import { displayNow, isDisplayToday } from "@/lib/timezone";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useSettingsStore } from "@/stores/settings-store";
import { useDisplayHours } from "@/hooks/use-display-hours";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import type { PendingEventPreview } from "./event-modal";
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
import { readableTextOn } from "@/lib/color-transform";
interface CalendarWeekViewProps extends ScrollWindowViewProps {
selectedDate: Date;
events: CalendarEvent[];
calendars: Calendar[];
onSelectDate: (date: Date) => void;
/** Open a day in the day view (clicking its date in the header). */
onOpenDay?: (date: Date) => void;
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
@@ -34,10 +42,14 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps {
timeFormat?: "12h" | "24h";
isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
/** The user's calendar addresses: moving an event they organize asks its attendees again. */
currentUserEmails?: string[];
tasks?: CalendarTask[];
onToggleTaskComplete?: (task: CalendarTask) => void;
onSelectTask?: (task: CalendarTask) => void;
/**
* The user's calendar addresses, to mark events they declined (#1110).
* Moving an event they organize also asks its attendees again.
*/
currentUserEmails?: string[];
}
const HOUR_HEIGHT = 60;
@@ -58,6 +70,7 @@ export function CalendarWeekView({
isLoading = false,
onVisibleDateChange,
onSelectDate,
onOpenDay,
onSelectEvent,
onHoverEvent,
onHoverLeave,
@@ -68,9 +81,10 @@ export function CalendarWeekView({
timeFormat = "24h",
isMobile,
pendingPreview,
currentUserEmails,
tasks,
onToggleTaskComplete,
onSelectTask,
currentUserEmails,
}: CalendarWeekViewProps) {
const t = useTranslations("calendar");
const describeError = useCalendarErrorMessage();
@@ -86,28 +100,53 @@ export function CalendarWeekView({
const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6;
const gutterWidth = isMobile ? 40 : 56;
// One column per loaded day (#759). Seven columns fill the viewport on
// desktop; the strip scrolls sideways and widens at either end.
const days = useMemo(
// One column per loaded day (#759). A week's columns fill the viewport on
// desktop; the strip scrolls sideways and widens at either end. Days the
// user does not work on can be left out (#1164).
const allDays = useMemo(
() => eachDayOfInterval({ start: rangeStart, end: rangeEnd }),
[rangeStart, rangeEnd],
);
const hideNonWorkingDays = useSettingsStore((s) => s.calendarHideNonWorkingDays);
const workingDaysSetting = useSettingsStore((s) => s.calendarWorkingDays);
const workingDays = useMemo(
() => resolveWorkingDays(hideNonWorkingDays, workingDaysSetting),
[hideNonWorkingDays, workingDaysSetting],
);
const { days, shownIndexByDay } = useMemo(() => {
if (!workingDays) return { days: allDays, shownIndexByDay: null };
const shown: Date[] = [];
const indices = allDays.map((day) => {
if (!workingDays.has(day.getDay())) return -1;
shown.push(day);
return shown.length - 1;
});
return shown.length > 0 ? { days: shown, shownIndexByDay: indices } : { days: allDays, shownIndexByDay: null };
}, [allDays, workingDays]);
const colCount = days.length;
const columnsPerWeek = shownIndexByDay && workingDays ? workingDays.size : 7;
const measureColWidth = useCallback((root: HTMLElement | null) => {
if (isMobile || !root) return MOBILE_COL_WIDTH;
return Math.max(MIN_COL_WIDTH, Math.floor((root.clientWidth - gutterWidth) / 7));
}, [isMobile, gutterWidth]);
return Math.max(MIN_COL_WIDTH, Math.floor((root.clientWidth - gutterWidth) / columnsPerWeek));
}, [isMobile, gutterWidth, columnsPerWeek]);
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
@@ -152,71 +191,90 @@ export function CalendarWeekView({
return timed;
}, [events]);
const {
hours, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes,
} = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT });
const firstHour = hours.startMinutes / 60;
const visibleHours = HOURS.slice(firstHour, hours.endMinutes / 60);
const gridHeight = visibleHours.length * HOUR_HEIGHT;
// Column layouts are the costly part of a render; with months of columns
// they must not be redone on every scroll-driven re-render.
const layoutByDay = useMemo(() => {
const map = new Map<string, ReturnType<typeof layoutOverlappingEvents>>();
const map = new Map<string, ReturnType<typeof partitionByDisplayHours> & { layouts: ReturnType<typeof layoutOverlappingEvents> }>();
for (const day of days) {
const key = format(day, "yyyy-MM-dd");
map.set(key, layoutOverlappingEvents(timedEvents.get(key) || [], day));
const partition = partitionByDisplayHours(timedEvents.get(key) || [], day, hours);
map.set(key, { ...partition, layouts: layoutOverlappingEvents(partition.visible, day) });
}
return map;
}, [days, timedEvents]);
}, [days, timedEvents, hours]);
// Built over the consecutive days and then moved onto the drawn columns,
// so an event across a hidden weekend still spans Friday to Monday.
const allDaySegments = useMemo(() => {
const explicitAllDay = buildWeekSegmentsRaw(
events.filter((event) => event.showWithoutTime),
days,
allDays,
);
const timedFullDay = buildTimedFullDayWeekSegments(
events.filter((event) => !event.showWithoutTime),
days,
allDays,
);
const segments = [...explicitAllDay, ...timedFullDay];
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
}, [events, days]);
return packWeekSegments(shownIndexByDay ? remapSegmentsToShownDays(segments, shownIndexByDay) : segments);
}, [events, allDays, shownIndexByDay]);
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 indexOfDayOnOrAfter(days, target);
}, [visibleDayKey, days, isMobile, focus.date, weekStart]);
const visibleColumns = isMobile ? Math.max(1, Math.ceil(viewportWidth / colWidth)) : columnsPerWeek;
// 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]);
useEffect(() => {
if (rootRef.current) {
const now = displayNow();
rootRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
}
}, []);
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);
// Navigation aligns the focused week (the focused day itself on mobile,
// where fewer columns fit) with the start of the viewport.
@@ -224,9 +282,8 @@ export function CalendarWeekView({
const root = rootRef.current;
if (!root) return;
const target = isMobile ? focus.date : startOfWeek(focus.date, { weekStartsOn: weekStart });
const index = Math.max(0, Math.min(colCount - 1, differenceInCalendarDays(target, rangeStart)));
setScrollStart(root, "horizontal", index * colWidth);
}, [focus.date, isMobile, weekStart, colCount, rangeStart, colWidth]);
setScrollStart(root, "horizontal", indexOfDayOnOrAfter(days, target) * colWidth);
}, [focus.date, isMobile, weekStart, days, colWidth]);
useScrollWindow({
scrollRef: rootRef,
@@ -271,6 +328,7 @@ export function CalendarWeekView({
const key = dayKey(day);
if (key === visibleKeyRef.current) return;
visibleKeyRef.current = key;
setVisibleDayKey(key);
onVisibleDateChange?.(day);
});
}, [colWidth, colCount, days, onVisibleDateChange]);
@@ -298,6 +356,8 @@ export function CalendarWeekView({
dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop,
} = useTimeGridInteractions({
hourHeight: HOUR_HEIGHT,
gridStartMinutes: hours.startMinutes,
gridEndMinutes: hours.endMinutes,
calendars,
onCreateRange: onCreateAtTime,
errorMessages: {
@@ -335,17 +395,90 @@ export function CalendarWeekView({
<div ref={startSentinelRef} data-testid="week-start-sentinel" className="absolute inset-y-0 start-0 w-px pointer-events-none" />
<div ref={endSentinelRef} data-testid="week-end-sentinel" className="absolute inset-y-0 end-0 w-px pointer-events-none" />
<div className="sticky top-0 z-50 bg-background">
<div className="flex border-b border-border" role="row">
<div className={cn(gutterClass, "flex items-end justify-center pb-1")}>
{canToggleHours && (
<AllHoursToggle
showAllHours={showAllHours}
configured={configuredHours}
timeFormat={timeFormat}
onToggle={toggleAllHours}
/>
)}
</div>
<div className="border-s border-border grid" style={columnsStyle}>
{days.map((day) => {
const todayCol = isDisplayToday(day);
const selected = isSameDay(day, selectedDate);
const fullLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
return (
<div
key={day.toISOString()}
role="columnheader"
aria-label={fullLabel}
data-day={dayKey(day)}
className="flex flex-col items-center gap-0.5 pt-2 pb-1.5 border-e border-border last:border-e-0"
>
<div className={cn(
"text-[11px] font-medium uppercase tracking-wide",
todayCol ? "text-primary" : "text-muted-foreground",
)}>
{intlFormatter.dateTime(day, { weekday: "short" })}
</div>
{/* The date opens that day in the day view. */}
<button
type="button"
onClick={() => (onOpenDay ?? onSelectDate)(day)}
aria-label={fullLabel}
aria-current={todayCol ? "date" : undefined}
className={cn(
"inline-flex items-center justify-center rounded-full transition-colors touch-manipulation",
isMobile ? "w-8 h-8 text-base" : "w-10 h-10 text-xl",
todayCol
? "bg-primary text-primary-foreground font-semibold hover:bg-primary/90"
: selected
? "bg-accent text-accent-foreground font-medium"
: "text-foreground hover:bg-muted",
)}
>
{format(day, "d")}
</button>
</div>
);
})}
</div>
</div>
{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
@@ -379,107 +512,64 @@ 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>
)}
<div className="flex border-b border-border" role="row">
<div className={gutterClass} />
<div className="border-s border-border grid" style={columnsStyle}>
{days.map((day) => {
const todayCol = isDisplayToday(day);
const selected = isSameDay(day, selectedDate);
const fullLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
return (
<button
key={day.toISOString()}
onClick={() => onSelectDate(day)}
role="columnheader"
aria-label={fullLabel}
data-day={dayKey(day)}
className={cn(
"text-center py-2 text-sm border-e border-border last:border-e-0 transition-colors touch-manipulation",
"hover:bg-muted/50",
todayCol && "font-bold",
)}
>
<div className="text-[10px] text-muted-foreground uppercase">
{intlFormatter.dateTime(day, { weekday: "short" })}
</div>
<div className={cn(
"inline-flex items-center justify-center w-7 h-7 rounded-full text-sm",
todayCol && "bg-primary text-primary-foreground",
selected && !todayCol && "bg-accent text-accent-foreground"
)}>
{format(day, "d")}
</div>
</button>
);
})}
</div>
</div>
</div>
<div>
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className="flex relative" style={{ height: gridHeight }}>
<div className={gutterClass}>
{HOURS.map((h) => (
{visibleHours.map((h) => (
<div
key={h}
className="relative text-muted-foreground text-end pe-2"
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
<span className={cn("absolute top-0 right-2 -translate-y-1/2 leading-none", isMobile ? "text-[9px]" : "text-[10px]")}>
{/* Midnight goes unlabelled; a later first hour is labelled
inside its row, where the header cannot cover it. */}
{(h > firstHour || firstHour > 0) && (
<span className={cn(
"absolute right-2 leading-none",
h > firstHour ? "top-0 -translate-y-1/2" : "top-1",
isMobile ? "text-[9px]" : "text-[10px]",
)}>
{formatHour(h)}
</span>
)}
@@ -491,7 +581,8 @@ export function CalendarWeekView({
{days.map((day) => {
const key = format(day, "yyyy-MM-dd");
const todayCol = isDisplayToday(day);
const layouted = layoutByDay.get(key) ?? [];
const dayLayout = layoutByDay.get(key);
const layouted = dayLayout?.layouts ?? [];
return (
<div
@@ -506,7 +597,7 @@ export function CalendarWeekView({
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, day)}
>
{HOURS.map((h) => (
{visibleHours.map((h) => (
<div
key={h}
role="gridcell"
@@ -519,10 +610,30 @@ export function CalendarWeekView({
/>
))}
{dayLayout && dayLayout.before > 0 && (
<HiddenEventsIndicator
count={dayLayout.before}
direction="before"
onReveal={() => revealMinutes(dayLayout.firstBeforeMinutes ?? 0)}
/>
)}
{dayLayout && dayLayout.after > 0 && (
<HiddenEventsIndicator
count={dayLayout.after}
direction="after"
onReveal={() => revealMinutes(dayLayout.firstAfterMinutes ?? hours.endMinutes)}
/>
)}
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
const clip = clipToDisplayHours(startMinutes, endMinutes, hours);
const baseHeight = Math.max(20, (Math.max(15, clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT);
// Short events at the bottom edge stay inside the grid.
const baseTop = Math.max(0, Math.min(
((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
gridHeight - baseHeight,
));
const isResizing = resizeVisual?.eventId === ev.id;
const top = isResizing ? resizeVisual!.topPx : baseTop;
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
@@ -545,8 +656,11 @@ export function CalendarWeekView({
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
draggable
/>
{/* An edge beyond the visible hours has nothing to grab. */}
{!clip.clippedStart && (
<div
data-resize-handle
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
@@ -557,6 +671,8 @@ export function CalendarWeekView({
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
</div>
)}
{!clip.clippedEnd && (
<div
data-resize-handle
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
@@ -567,18 +683,19 @@ export function CalendarWeekView({
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mb-0.5" />
</div>
)}
</div>
);
})}
{todayCol && (
{todayCol && nowMinutes >= hours.startMinutes && nowMinutes <= hours.endMinutes && (
<div
className="absolute left-0 right-0 z-20 pointer-events-none"
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
style={{ top: ((nowMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-destructive -ms-1" />
<div className="flex-1 h-px bg-destructive" />
<div className="flex items-center -translate-y-1/2">
<div className="w-3 h-3 rounded-full bg-destructive -ms-1.5 flex-shrink-0" />
<div className="flex-1 h-0.5 bg-destructive" />
</div>
</div>
)}
@@ -595,7 +712,7 @@ export function CalendarWeekView({
<div
className="absolute left-1 right-1 z-30 rounded-md pointer-events-none bg-primary/15 border-2 border-primary/30 border-dashed"
style={{
top: (dragCreate.startMinutes / 60) * HOUR_HEIGHT,
top: ((dragCreate.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
height: ((dragCreate.endMinutes - dragCreate.startMinutes) / 60) * HOUR_HEIGHT,
}}
>
@@ -608,7 +725,7 @@ export function CalendarWeekView({
{dropTarget?.dayKey === key && (
<div
className="absolute left-0 right-0 z-30 pointer-events-none"
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
style={{ top: ((dropTarget.minutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-primary -ms-1" />
@@ -626,22 +743,27 @@ export function CalendarWeekView({
let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
if (endMin <= startMin) endMin = 1440;
const durationMin = Math.max(15, endMin - startMin);
if (startMin + durationMin <= hours.startMinutes || startMin >= hours.endMinutes) return null;
const clip = clipToDisplayHours(startMin, startMin + durationMin, hours);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
const color = cal?.color || "#3b82f6";
// Drawn like a saved event, so the new event shows where
// it will land while it is being created.
return (
<div
className="absolute left-1 right-1 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
data-pending-preview
className="absolute left-0.5 right-0.5 z-30 rounded-xs pointer-events-none overflow-hidden px-1.5 py-0.5 text-xs shadow-md"
style={{
top: (startMin / 60) * HOUR_HEIGHT,
height: Math.max(20, (durationMin / 60) * HOUR_HEIGHT),
borderColor: color,
backgroundColor: `${color}10`,
top: ((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
height: Math.max(20, ((clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT),
backgroundColor: color,
color: readableTextOn(color),
}}
>
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
<div className="font-medium truncate">
{pendingPreview.title}
</div>
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
<div className="text-[10.5px] opacity-85 truncate">
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
</div>
</div>
@@ -0,0 +1,78 @@
"use client";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import { ChevronDown, ChevronUp, FoldVertical, UnfoldVertical } from "@/components/icons";
import { formatDisplayHour, type DisplayHours } from "@/lib/calendar-display-range";
interface HiddenEventsIndicatorProps {
count: number;
direction: "before" | "after";
onReveal: () => void;
}
/**
* Marks a day column that has events above or below the visible hours
* (#1164); clicking it shows the whole day at the first of them.
*/
export function HiddenEventsIndicator({ count, direction, onReveal }: HiddenEventsIndicatorProps) {
const t = useTranslations("calendar.events");
const label = direction === "before" ? t("hidden_before", { count }) : t("hidden_after", { count });
const Icon = direction === "before" ? ChevronUp : ChevronDown;
return (
<button
type="button"
data-hidden-events={direction}
title={label}
aria-label={label}
// The column starts drag-to-create on pointer down.
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); onReveal(); }}
className={cn(
"absolute inset-x-0 mx-auto w-fit z-20 flex items-center gap-0.5 rounded-full",
"bg-muted/90 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground",
"hover:bg-accent hover:text-foreground transition-colors cursor-pointer",
direction === "before" ? "top-0.5" : "bottom-0.5",
)}
>
<Icon className="h-3 w-3" />
<span>{count}</span>
</button>
);
}
interface AllHoursToggleProps {
showAllHours: boolean;
configured: DisplayHours;
timeFormat: "12h" | "24h";
onToggle: () => void;
className?: string;
}
/** Switches a time grid between the configured hours and the whole day. */
export function AllHoursToggle({ showAllHours, configured, timeFormat, onToggle, className }: AllHoursToggleProps) {
const t = useTranslations("calendar.events");
const label = showAllHours
? t("show_display_hours", {
start: formatDisplayHour(configured.startMinutes / 60, timeFormat),
end: formatDisplayHour(configured.endMinutes / 60, timeFormat),
})
: t("show_all_hours");
const Icon = showAllHours ? FoldVertical : UnfoldVertical;
return (
<button
type="button"
data-all-hours-toggle
aria-pressed={showAllHours}
aria-label={label}
title={label}
onClick={onToggle}
className={cn(
"flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer",
className,
)}
>
<Icon className="h-3.5 w-3.5" />
</button>
);
}
+58 -33
View File
@@ -6,9 +6,10 @@ import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { format } from "date-fns";
import { Users } from "@/components/icons";
import { getParticipantCount } from "@/lib/calendar-participants";
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,32 @@ 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]">
{/* Shorter blocks have no room for a second line at the 60px hour, so
their start time follows the title on the same line, when the
block is wide enough to keep the title readable. */}
<div className="@container">
<div className="flex items-baseline gap-1 min-w-0">
<span className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
{!event.showWithoutTime && durationMinutes < 45 && (
<span className="hidden @min-[120px]:inline flex-shrink-0 opacity-85 text-[10.5px]">{safeFormat(startDate, timeFmt)}</span>
)}
</div>
</div>
{!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" />
+2 -3
View File
@@ -18,6 +18,7 @@ import {
import type { CalendarEvent } from "@/lib/jmap/types";
import { buildCalendarPath } from "@/lib/deep-links";
import { useCopyLink } from "@/hooks/use-copy-link";
import { findMeetingLink } from "@/lib/event-links";
interface Position {
x: number;
@@ -60,9 +61,7 @@ export function EventContextMenu({
onClose();
};
const hasMeetingLink = !!(
event.virtualLocations && Object.values(event.virtualLocations).some((v) => v.uri)
);
const hasMeetingLink = !!findMeetingLink(event);
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
+149 -116
View File
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { LinkifiedText } from "@/components/ui/linkified-text";
import {
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
Pencil, Trash2, Copy, Send, Check,
Pencil, Trash2, Copy, Send, Check, MoreVertical, Calendar as CalendarIcon,
} from "@/components/icons";
import { format, isSameDay } from "date-fns";
import { cn } from "@/lib/utils";
@@ -23,6 +23,7 @@ import {
import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
interface EventDetailPopoverProps {
event: CalendarEvent;
@@ -31,6 +32,8 @@ interface EventDetailPopoverProps {
onEdit: () => void;
onDelete: () => void;
onDuplicate: () => void;
/** Opens the event's full action menu (export, copy, ...) at the click. */
onMore?: (e: React.MouseEvent) => void;
onClose: () => void;
onSaveNote: (note: string) => void;
onRsvp?: (status: CalendarParticipant["participationStatus"]) => void;
@@ -43,6 +46,7 @@ interface EventDetailPopoverProps {
}
const POPOVER_WIDTH = 360;
const HEADER_BUTTON = "p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground";
const POPOVER_GAP = 8;
const VIEWPORT_MARGIN = 12;
const MAX_HEIGHT = 480;
@@ -117,6 +121,7 @@ export function EventDetailPopover({
onEdit,
onDelete,
onDuplicate,
onMore,
onClose,
onSaveNote,
onRsvp,
@@ -149,16 +154,36 @@ export function EventDetailPopover({
const displayEndDate = getEventDisplayEndDate(event);
const isMultiDay = !isSameDay(startDate, displayEndDate);
const locationName = useMemo(() => {
if (!event.locations) return null;
return Object.values(event.locations)[0]?.name || null;
}, [event.locations]);
const locationName = useMemo(() => primaryLocationName(event) ?? null, [event]);
const virtualLocation = useMemo(() => {
if (!event.virtualLocations) return null;
const first = Object.values(event.virtualLocations)[0];
return first?.uri || null;
}, [event.virtualLocations]);
// Invitations (Teams above all) leave virtualLocations empty and bury the
// join URL in the description; surface it as the meeting link anyway.
const meeting = useMemo(() => findMeetingLink(event), [event]);
const virtualLocation = meeting?.uri ?? null;
// A URL opens as a link, a location that only names the service joins the
// meeting, a postal address opens in the maps.
const locationHref = useMemo(() => {
if (!locationName) return null;
const action = locationAction(locationName, meeting);
return action.kind === "url" ? action.uri : mapsUrl(action.query);
}, [locationName, meeting]);
const [locationCopied, setLocationCopied] = useState(false);
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (copiedTimer.current) clearTimeout(copiedTimer.current);
}, []);
const copyLocation = useCallback(async () => {
if (!locationName) return;
try {
await navigator.clipboard.writeText(locationName);
setLocationCopied(true);
if (copiedTimer.current) clearTimeout(copiedTimer.current);
copiedTimer.current = setTimeout(() => setLocationCopied(false), 1500);
} catch {
// clipboard refused (insecure context or permission) - nothing to do
}
}, [locationName]);
const participants = useMemo(
() => getParticipantList(event, { resolveName: resolveContactName }),
@@ -303,49 +328,99 @@ export function EventDetailPopover({
visibility: position ? "visible" : "hidden",
}}
>
{/* Color accent bar */}
<div className="h-1 w-full" style={{ backgroundColor: color }} />
{/* Header */}
<div className="flex items-start justify-between gap-2 px-4 pt-3 pb-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
<h3 className={cn(
"text-base font-semibold truncate text-foreground",
event.status === "cancelled" && "line-through text-muted-foreground"
)}>
{event.title || t("events.no_title")}
</h3>
</div>
{calendar && (
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">
{calendar.name}
{event.status === "tentative" && (
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
{t("detail.tentative")}
</span>
)}
{event.status === "cancelled" && (
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
{t("detail.cancelled")}
</span>
)}
</p>
)}
</div>
{/* Actions sit at the top, the event's colour square beside its title. */}
<div className="flex items-center justify-end gap-0.5 px-2 pt-2">
{canEditBody && (
<button
type="button"
onClick={onEdit}
className={HEADER_BUTTON}
aria-label={t("events.edit")}
title={t("events.edit")}
>
<Pencil className="w-4 h-4" />
</button>
)}
{canEditBody && (
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className={HEADER_BUTTON}
aria-label={t("events.delete")}
title={t("events.delete")}
>
<Trash2 className="w-4 h-4" />
</button>
)}
<button
type="button"
onClick={onMore ?? onDuplicate}
className={HEADER_BUTTON}
aria-label={onMore ? t("toolbar.more") : t("events.duplicate")}
title={onMore ? t("toolbar.more") : t("events.duplicate")}
>
{onMore ? <MoreVertical className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground"
className={HEADER_BUTTON}
aria-label={t("form.cancel")}
>
<X className="w-4 h-4" />
</button>
</div>
{showDeleteConfirm && (
<div className="mx-4 mt-1 mb-2 flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2">
<span className="text-sm text-destructive flex-1">
{t("detail.delete_confirm")}
</span>
<Button
variant="destructive"
size="sm"
onClick={onDelete}
className="h-7 text-xs"
>
{t("events.delete")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(false)}
className="h-7 text-xs"
>
{t("form.cancel")}
</Button>
</div>
)}
<div className="flex items-start gap-2.5 px-4 pb-1">
<span
className="w-4 h-4 rounded-sm flex-shrink-0 mt-1"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<h3 className={cn(
"text-lg font-semibold leading-snug break-words text-foreground",
event.status === "cancelled" && "line-through text-muted-foreground"
)}>
{event.title || t("events.no_title")}
</h3>
{event.status === "tentative" && (
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
{t("detail.tentative")}
</span>
)}
{event.status === "cancelled" && (
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
{t("detail.cancelled")}
</span>
)}
</div>
</div>
{/* Content */}
<div className={cn(
"px-4 py-2 space-y-2.5 overflow-y-auto",
@@ -407,21 +482,28 @@ export function EventDetailPopover({
{locationName && (
<div className="flex items-start gap-2.5">
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{/^https?:\/\//i.test(locationName) ? (
<a
href={locationName}
target="_blank"
rel="noreferrer"
className="text-sm text-primary hover:underline truncate"
title={locationName}
>
{(() => {
<a
href={locationHref ?? undefined}
target="_blank"
rel="noreferrer"
className="text-sm text-primary hover:underline min-w-0 break-words"
title={locationName}
>
{/^https?:\/\//i.test(locationName)
? (() => {
try { return new URL(locationName).hostname; } catch { return locationName; }
})()}
</a>
) : (
<span className="text-sm text-foreground">{locationName}</span>
)}
})()
: locationName}
</a>
<button
type="button"
onClick={copyLocation}
className="ms-auto p-0.5 text-muted-foreground hover:text-foreground flex-shrink-0"
title={t("detail.copy_location")}
aria-label={t("detail.copy_location")}
>
{locationCopied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
)}
@@ -495,6 +577,14 @@ export function EventDetailPopover({
</div>
)}
{/* Calendar */}
{calendar && (
<div className="flex items-start gap-2.5">
<CalendarIcon className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span className="text-sm text-foreground truncate">{calendar.name}</span>
</div>
)}
{/* Description */}
{event.description && (
<div className="flex items-start gap-2.5">
@@ -606,63 +696,6 @@ export function EventDetailPopover({
</div>
)}
{/* Action Bar */}
<div className="px-4 py-2.5 border-t border-border flex items-center gap-1.5">
{showDeleteConfirm ? (
<div className="flex items-center gap-2 w-full">
<span className="text-sm text-destructive flex-1">
{t("form.delete_confirm")}
</span>
<Button
variant="outline"
size="sm"
onClick={onDelete}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 h-7 text-xs"
>
{t("events.delete")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(false)}
className="h-7 text-xs"
>
{t("form.cancel")}
</Button>
</div>
) : (
<>
{canEditBody && (
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
<Pencil className="w-3.5 h-3.5 me-1" />
{t("events.edit")}
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={onDuplicate}
className="h-7 text-xs"
title={t("events.duplicate")}
>
<Copy className="w-3.5 h-3.5 me-1" />
{t("events.duplicate")}
</Button>
<div className="flex-1" />
{canEditBody && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="h-7 text-xs text-red-600 dark:text-red-400"
title={t("events.delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</>
)}
</div>
</div>
);
+91 -33
View File
@@ -2,11 +2,12 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations, useLocale } from "next-intl";
import { toast } from "@/stores/toast-store";
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, AlertTriangle } from "@/components/icons";
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
import { format, parseISO, addHours, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
import { parseDuration, getEventColor } from "./event-card";
@@ -30,10 +31,15 @@ import { changesWhen, type EditorAttendee, type EventEditorState } from "@/lib/r
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useIsPaneScoped } from "@/hooks/use-pane-context";
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { calendarHooks } from "@/lib/plugin-hooks";
import {
calendarFormHooks,
calendarHooks,
type CalendarEventFormSavePatch,
} from "@/lib/plugin-hooks";
import type { ConflictWarning } from "@/lib/plugin-types";
export interface PendingEventPreview {
@@ -50,6 +56,10 @@ interface EventModalProps {
defaultDate?: Date;
defaultEndDate?: Date;
defaultAllDay?: boolean;
/** Open an existing event straight in the form instead of its summary. */
startInEditMode?: boolean;
/** Title to start a new event with (from the quick editor's "More options"). */
defaultTitle?: string;
defaultCalendarId?: string;
/**
* `editor` is what the editor had besides `data`, for a save that covers
@@ -291,6 +301,8 @@ export function EventModal({
defaultDate,
defaultEndDate,
defaultAllDay,
defaultTitle,
startInEditMode,
defaultCalendarId,
onSave,
onDelete,
@@ -317,7 +329,7 @@ export function EventModal({
: "fixed inset-0 z-50 flex flex-col bg-background";
const isEdit = !!event;
const formatEventDate = useFormatEventDate();
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
const [mode, setMode] = useState<"view" | "edit">(isEdit && !startInEditMode ? "view" : "edit");
// Only the organizer invites: someone else's invitation keeps its
// attendees as the organizer sent them, and a save of the user's own copy
@@ -402,7 +414,7 @@ export function EventModal({
return addHours(getInitialStart(), 1);
};
const [title, setTitle] = useState(event?.title || "");
const [title, setTitle] = useState(event?.title || defaultTitle || "");
const [description, setDescription] = useState(event?.description || "");
const [location, setLocation] = useState(
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
@@ -499,6 +511,8 @@ export function EventModal({
}, []);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Set synchronously, so a second save in the same tick is refused too.
const savingRef = useRef(false);
// The attendees the editor opened with: a save for every occurrence of a
// series applies only those the user added or removed.
@@ -560,12 +574,14 @@ export function EventModal({
location,
virtualLocation,
calendarId,
uid: event?.uid,
attendees: attendees.map((a) => a.email),
},
});
if (!cancelled) setPluginConflictWarnings(warnings);
}, 250);
return () => { cancelled = true; clearTimeout(t); };
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId, event?.uid, attendees]);
// Report live preview to parent for grid outline
useEffect(() => {
@@ -594,8 +610,14 @@ export function EventModal({
const handleSave = useCallback(async () => {
const trimmedTitle = title.trim();
if (!trimmedTitle || isSaving) return;
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
if (!trimmedTitle || isSaving || savingRef.current) return;
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) {
// This guard returned silently, so a rejected save was indistinguishable from a dead
// button. The fields also enforce their limits via maxLength, so this is a backstop —
// but if it ever fires, say so.
toast.error(t("notifications.event_error"));
return;
}
const pendingAttendee = participantInputRef.current?.flush() ?? null;
const effectiveAttendees = pendingAttendee ? [...attendees, pendingAttendee] : attendees;
@@ -633,9 +655,10 @@ export function EventModal({
// grid and prefill are display dates), so label them with that zone.
const timeZone = getEffectiveTimeZone();
const trimmedDescription = description.trim();
const data: Partial<CalendarEvent> = {
title: trimmedTitle,
description: description.trim(),
start: startStr,
duration,
timeZone: allDay ? null : timeZone,
@@ -644,6 +667,13 @@ export function EventModal({
...savedStatusFields(event),
};
// On an existing event the empty string is how a description gets cleared, but
// sending it on creation writes a `DESCRIPTION:` with nothing after it — which
// invitation e-mails then render as a "Description" heading over blank space.
if (trimmedDescription || event) {
data.description = trimmedDescription;
}
if (!event) {
data.uid = generateUUID();
}
@@ -717,9 +747,53 @@ export function EventModal({
}));
}
const shouldSendScheduling = userIsOrganizer && effectiveAttendees.length > 0 && sendInvitations;
// Plugin form-save hook: a handler may refresh form-derived values
// (currently the virtual-location link) in the same save that sends
// scheduling messages, so invites carry what plugins set here rather
// than a stale value from before the save.
// The handlers can take seconds (a plugin minting a meeting room), so the
// save counts as in flight from here: a second click or Ctrl+Enter must
// not start another save, which for a new event would create a second
// copy (fresh uid) and send its invitations twice.
savingRef.current = true;
setIsSaving(true);
try {
const pluginPatch = await calendarFormHooks.onCalendarEventFormSave.transform<
CalendarEventFormSavePatch
>(
{},
{
title: trimmedTitle,
description: trimmedDescription,
start: startStr,
end: allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`,
isAllDay: allDay,
location,
virtualLocation,
calendarId,
uid: data.uid ?? event?.uid,
attendees: effectiveAttendees.map((a) => a.email),
},
);
if (pluginPatch && typeof pluginPatch.virtualLocation === "string") {
if (pluginPatch.virtualLocation.trim()) {
data.virtualLocations = {
vl1: {
"@type": "VirtualLocation",
name: null,
description: null,
uri: pluginPatch.virtualLocation.trim(),
features: null,
},
};
} else if (event) {
data.virtualLocations = null;
} else {
delete data.virtualLocations;
}
}
const shouldSendScheduling = userIsOrganizer && effectiveAttendees.length > 0 && sendInvitations;
await onSave(data, shouldSendScheduling, {
initialAttendees,
attendees: effectiveAttendees,
@@ -727,9 +801,10 @@ export function EventModal({
ruleChanged,
});
} finally {
savingRef.current = false;
setIsSaving(false);
}
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, alertRows, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, onSave, isSaving]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, alertRows, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, onSave, isSaving, t]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
@@ -739,28 +814,7 @@ export function EventModal({
const handleDuplicate = useCallback(() => {
if (!event || !onDuplicate) return;
const start = getEventStartDate(event);
const newStart = addDays(start, 1);
const newUid = generateUUID();
const data: Partial<CalendarEvent> = {
uid: newUid,
title: event.title,
description: event.description,
start: event.showWithoutTime ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
duration: event.duration,
timeZone: event.timeZone,
showWithoutTime: event.showWithoutTime,
calendarIds: { ...event.calendarIds },
status: "confirmed",
freeBusyStatus: event.freeBusyStatus,
privacy: event.privacy,
};
if (event.locations) data.locations = structuredClone(event.locations);
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
if (event.alerts) data.alerts = structuredClone(event.alerts);
if (event.participants) data.participants = structuredClone(event.participants);
onDuplicate(data);
onDuplicate(buildDuplicateEventData(event));
}, [event, onDuplicate]);
const modalRef = useRef<HTMLDivElement>(null);
@@ -1208,6 +1262,8 @@ export function EventModal({
location,
virtualLocation,
calendarId,
uid: event?.uid,
attendees: attendees.map((a) => a.email),
},
setVirtualLocation,
}}
@@ -1547,7 +1603,9 @@ export function EventModal({
disabled={!title.trim() || isSaving}
className="w-full"
>
{t("form.save")}
{/* While a save is in flight the button was disabled with no visible change, so a
slow request (mobile, VPN) looked like a dead button for the whole timeout. */}
{isSaving ? t("subscription.saving") : t("form.save")}
</Button>
</div>
)}
+7 -4
View File
@@ -187,13 +187,16 @@ export function MiniCalendar({
className={cn(
"relative flex items-center justify-center w-7 h-7 text-xs rounded-full transition-colors",
!inMonth && "text-muted-foreground/40",
inMonth && !selected && "hover:bg-muted",
today && !selected && "font-bold text-primary",
selected && "bg-primary text-primary-foreground"
// Today is the filled circle; the selected day is tinted.
today && "bg-primary text-primary-foreground font-semibold",
selected && !today && "bg-primary/15 text-primary font-semibold",
!today && !selected && "hover:bg-muted"
)}
aria-current={today ? "date" : undefined}
aria-pressed={selected}
>
{formatDayNumber(day)}
{hasEvent && !selected && (
{hasEvent && !selected && !today && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)}
</button>
+36 -4
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from "react";
import { useState, useRef, useCallback, useEffect, useLayoutEffect, forwardRef, useImperativeHandle } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { X } from "@/components/icons";
import { Input } from "@/components/ui/input";
@@ -33,6 +34,29 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// The list is rendered into <body> at a fixed position. Inside the event
// form's scroll container it was clipped: overflow-y also clips sideways,
// which cut off the avatars that sit left of the field.
const [listPos, setListPos] = useState<{ top: number; left: number; width: number } | null>(null);
useLayoutEffect(() => {
if (!showSuggestions) return;
const place = () => {
const rect = inputRef.current?.getBoundingClientRect();
if (!rect) return;
// 36px left of the field: past the list's 1px border, 12px padding and
// the 28px avatar plus gap, each name starts under the typed text. Kept
// inside the window on a narrow screen.
const left = Math.max(8, rect.left - 36);
setListPos({ top: rect.bottom + 4, left, width: rect.right - left });
};
place();
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [showSuggestions]);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const updateSuggestions = useCallback((q: string) => {
@@ -115,8 +139,14 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
return (
<div className="space-y-2">
<div className="relative">
{/* type="search": Bitwarden skips search fields unconditionally and
otherwise offered logins for the e-mail placeholder. */}
<Input
ref={inputRef}
type="search"
autoComplete="off"
enterKeyHint="enter"
className="[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none"
value={query}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
@@ -131,12 +161,13 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
aria-autocomplete="list"
/>
{showSuggestions && (
{showSuggestions && listPos && createPortal(
<ul
ref={listRef}
id="participant-suggestions"
role="listbox"
className="absolute z-50 w-full mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto"
style={{ top: listPos.top, left: listPos.left, width: listPos.width }}
className="fixed z-[60] bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto"
>
{suggestions.map((s, i) => (
<li
@@ -158,7 +189,8 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
</div>
</li>
))}
</ul>
</ul>,
document.body,
)}
</div>
@@ -0,0 +1,209 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { format } from "date-fns";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Clock, X } from "@/components/icons";
import type { Calendar } from "@/lib/jmap/types";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
export interface QuickCreateDraft {
start: Date;
end: Date;
title: string;
calendarId: string;
}
interface QuickCreatePopoverProps {
draft: QuickCreateDraft;
/** Calendars new events can go into. */
calendars: Calendar[];
timeFormat?: "12h" | "24h";
onChange: (draft: QuickCreateDraft) => void;
onSave: () => void;
onMoreOptions: () => void;
onClose: () => void;
}
const WIDTH = 340;
const GAP = 8;
const MARGIN = 12;
// The views draw the new event's placeholder block with this attribute; the
// popover sits beside it, or under it when neither side has room.
const PLACEHOLDER_SELECTOR = "[data-pending-preview]";
function computePosition(anchor: DOMRect | null, height: number): { top: number; left: number } {
const vw = window.innerWidth;
const vh = window.innerHeight;
if (!anchor) return { top: Math.max(MARGIN, (vh - height) / 2), left: Math.max(MARGIN, (vw - WIDTH) / 2) };
const clampTop = (top: number) => Math.min(Math.max(MARGIN, top), vh - height - MARGIN);
const clampLeft = (left: number) => Math.min(Math.max(MARGIN, left), vw - WIDTH - MARGIN);
if (anchor.right + GAP + WIDTH + MARGIN <= vw) return { top: clampTop(anchor.top), left: anchor.right + GAP };
if (anchor.left - GAP - WIDTH >= MARGIN) return { top: clampTop(anchor.top), left: anchor.left - GAP - WIDTH };
if (anchor.bottom + GAP + height + MARGIN <= vh) return { top: anchor.bottom + GAP, left: clampLeft(anchor.left) };
return { top: Math.max(MARGIN, anchor.top - GAP - height), left: clampLeft(anchor.left) };
}
/**
* Compact editor that opens next to an empty slot clicked in the week or
* day view: a title, the time that was picked, the calendar, and a way into
* the full editor. Enter saves, Escape or a click elsewhere discards.
*/
export function QuickCreatePopover({
draft,
calendars,
timeFormat = "24h",
onChange,
onSave,
onMoreOptions,
onClose,
}: QuickCreatePopoverProps) {
const t = useTranslations("calendar");
const intlFormatter = useDisplayDateFormatter();
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
// Re-anchor when the draft moves (a new slot) and once the placeholder
// block has been drawn by the view.
const startKey = draft.start.getTime();
const endKey = draft.end.getTime();
useLayoutEffect(() => {
const place = () => {
const node = ref.current;
if (!node) return;
const anchor = document.querySelector(PLACEHOLDER_SELECTOR)?.getBoundingClientRect() ?? null;
setPosition(computePosition(anchor, node.offsetHeight));
};
place();
const frame = requestAnimationFrame(place);
return () => cancelAnimationFrame(frame);
}, [startKey, endKey]);
// Focus once placed. Chrome sometimes refuses the first focus() right
// after the slot click that opened the popover, so try again briefly.
const placed = position !== null;
useEffect(() => {
if (!placed) return;
let attempts = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
const tryFocus = () => {
const el = inputRef.current;
if (!el) return;
el.focus();
if (document.activeElement !== el && ++attempts < 5) timer = setTimeout(tryFocus, 50);
};
const frame = requestAnimationFrame(tryFocus);
return () => {
cancelAnimationFrame(frame);
if (timer) clearTimeout(timer);
};
}, [startKey, placed]);
useEffect(() => {
const handleMouseDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
const timer = setTimeout(() => document.addEventListener("mousedown", handleMouseDown), 0);
document.addEventListener("keydown", handleKey);
return () => {
clearTimeout(timer);
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKey);
};
}, [onClose]);
const canSave = draft.title.trim().length > 0 && !!draft.calendarId;
const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
if (canSave) onSave();
}, [canSave, onSave]);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const dateLabel = intlFormatter.dateTime(draft.start, { weekday: "long", month: "long", day: "numeric" });
const timeLabel = `${format(draft.start, timeFmt)} – ${format(draft.end, timeFmt)}`;
return createPortal(
<div
ref={ref}
role="dialog"
aria-label={t("quick_create.aria_label")}
className="fixed z-[60] bg-background border border-border rounded-lg shadow-xl"
style={{
width: WIDTH,
top: position?.top ?? -9999,
left: position?.left ?? -9999,
visibility: position ? "visible" : "hidden",
}}
>
<form onSubmit={handleSubmit}>
<div className="flex justify-end px-2 pt-2">
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
aria-label={t("form.cancel")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-4 pb-3 space-y-3">
<Input
ref={inputRef}
value={draft.title}
onChange={(e) => onChange({ ...draft, title: e.target.value })}
placeholder={t("quick_create.add_title")}
aria-label={t("form.title")}
maxLength={500}
className="text-base"
/>
<div className="flex items-start gap-2.5 text-sm">
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div>
<div className="font-medium text-foreground">{dateLabel}</div>
<div className="text-muted-foreground">{timeLabel}</div>
</div>
</div>
{calendars.length > 0 && (
<div className="flex items-center gap-2.5">
<span
className="w-4 h-4 rounded-sm flex-shrink-0"
style={{ backgroundColor: calendars.find((c) => c.id === draft.calendarId)?.color || "#3b82f6" }}
aria-hidden="true"
/>
<select
value={draft.calendarId}
onChange={(e) => onChange({ ...draft, calendarId: e.target.value })}
aria-label={t("form.calendar_select")}
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{calendars.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-4 py-2.5 border-t border-border">
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={onMoreOptions}>
{t("quick_create.more_options")}
</Button>
<Button type="submit" size="sm" className="h-8" disabled={!canSave}>
{t("form.save")}
</Button>
</div>
</form>
</div>,
document.body,
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import { Check } from "@/components/icons";
import type { Calendar, CalendarTask } from "@/lib/jmap/types";
interface CalendarTaskChipProps {
task: CalendarTask;
calendar?: Calendar;
onToggleComplete?: (task: CalendarTask) => void;
onSelect?: (task: CalendarTask) => void;
className?: string;
}
/**
* A task on its due day in the month and week grids (#1107). The circle
* toggles completion; the title opens the task in the editor.
*/
export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, className }: CalendarTaskChipProps) {
const t = useTranslations("calendar");
const isCompleted = task.progress === "completed";
const color = calendar?.color || "#3b82f6";
const title = task.title || t("tasks.no_title");
return (
<div
data-calendar-task={task.id}
className={cn("h-full rounded text-[10px] font-medium flex items-center overflow-hidden", className)}
style={{ backgroundColor: `${color}20`, borderInlineStart: `3px solid ${color}` }}
>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onToggleComplete?.(task); }}
className="group h-full ps-1 pe-0.5 flex items-center flex-shrink-0"
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
>
<span
className={cn(
"w-2.5 h-2.5 rounded-full border flex items-center justify-center",
isCompleted ? "bg-success border-success text-success-foreground" : "group-hover:bg-background",
)}
style={isCompleted ? undefined : { borderColor: color }}
>
{isCompleted && <Check className="h-2 w-2" />}
</span>
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onSelect?.(task); }}
className={cn(
"h-full flex-1 min-w-0 truncate text-start pe-1 hover:opacity-80",
isCompleted && "line-through text-muted-foreground",
)}
title={title}
>
{title}
</button>
</div>
);
}
@@ -71,6 +71,17 @@ describe('ContactList', () => {
expect(screen.getByText('empty_state_title')).toBeInTheDocument();
});
it('shows placeholders instead of the empty state while contacts are loading', () => {
render(<ContactList {...defaultProps} contacts={[]} isLoading />);
expect(screen.queryByText('empty_state_title')).toBeNull();
expect(screen.getByTestId('contact-list-skeleton')).toBeInTheDocument();
});
it('shows the contacts as soon as they are there, even while loading', () => {
render(<ContactList {...defaultProps} isLoading />);
expect(screen.queryByTestId('contact-list-skeleton')).toBeNull();
});
it('shows search empty state when search has no results', () => {
render(<ContactList {...defaultProps} searchQuery="zzz" />);
expect(screen.getByText('empty_search')).toBeInTheDocument();
+3 -2
View File
@@ -8,6 +8,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { Avatar } from "@/components/ui/avatar";
import { cleanPreview } from "@/lib/utils";
import { Section } from "./contact-detail";
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
@@ -244,9 +245,9 @@ export function ContactActivity({ contact }: ContactActivityProps) {
<div className="text-sm truncate">
{email.subject || t("no_subject")}
</div>
{email.preview && (
{cleanPreview(email.preview) && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{email.preview}
{cleanPreview(email.preview)}
</p>
)}
</div>
+8 -14
View File
@@ -6,6 +6,7 @@ import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Co
import { Avatar } from "@/components/ui/avatar";
import { MailtoLink } from "@/components/ui/mailto-link";
import { Button } from "@/components/ui/button";
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoRef } from "@/stores/contact-store";
@@ -583,31 +584,24 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string })
{open && (
<div
role="menu"
className="absolute end-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
className="absolute end-0 top-full mt-1 z-30 min-w-[200px] bg-background rounded-md shadow-lg border border-border py-1"
>
{items.map((item, i) => {
if (item.separator) {
return <div key={i} role="separator" className="my-1 h-px bg-border" />;
return <ContextMenuSeparator key={i} />;
}
return (
<button
<ContextMenuItem
key={i}
type="button"
role="menuitem"
icon={item.icon}
label={item.label}
destructive={item.destructive}
disabled={item.disabled}
onClick={() => {
item.onClick();
setOpen(false);
}}
className={cn(
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent",
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
)}
>
<item.icon className={cn("w-4 h-4 flex-shrink-0", item.destructive ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} />
<span className="flex-1">{item.label}</span>
</button>
/>
);
})}
</div>
+16 -1
View File
@@ -115,6 +115,8 @@ interface ContactListProps {
onAddContactToGroup: (id: string) => void;
/** Show a burger button at the start that opens the (overlay) categories sidebar. */
onMenuClick?: () => void;
/** The first fetch is still running: show placeholders, not "No contacts yet". */
isLoading?: boolean;
}
export function ContactList({
@@ -137,6 +139,7 @@ export function ContactList({
onDeleteContact,
onAddContactToGroup,
onMenuClick,
isLoading = false,
}: ContactListProps) {
const t = useTranslations("contacts");
const locale = useLocale();
@@ -530,7 +533,19 @@ export function ContactList({
{/* Contact list */}
<div className="flex-1 overflow-y-auto">
{sorted.length === 0 ? (
{sorted.length === 0 && isLoading && !searchQuery && activeFilters === 0 ? (
<div aria-hidden className="py-1" data-testid="contact-list-skeleton">
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
<div className="w-9 h-9 rounded-full bg-muted animate-pulse shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<div className="h-3.5 rounded bg-muted animate-pulse" style={{ width: `${45 + ((i * 29) % 35)}%` }} />
<div className="h-3 rounded bg-muted/70 animate-pulse" style={{ width: `${30 + ((i * 17) % 30)}%` }} />
</div>
</div>
))}
</div>
) : sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
{searchQuery || activeFilters > 0 ? (
<>
+21 -3
View File
@@ -45,6 +45,9 @@ import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/dee
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
type View =
| "list"
@@ -65,6 +68,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('contacts', routeSegments);
const t = useTranslations("contacts");
const tSidebar = useTranslations("sidebar");
useDocumentTitle(tSidebar("contacts"));
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
@@ -76,6 +81,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
selectedContactId,
searchQuery,
supportsSync,
contactsLoaded,
selectedContactIds,
setSelectedContact,
setSearchQuery,
@@ -125,6 +131,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded();
// Mountain View's desktop chrome: its top bar in place of the left rail.
const mvShell = useMountainViewShell(isEmbedded);
const router = useRouter();
const searchParams = useSearchParams();
// One-shot intent flag: only consume the URL params on the first render that
@@ -905,12 +913,21 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
};
return (
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
{refreshIndicator}
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{mvShell && (
<MvTopBar
showMenuButton={false}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
<div data-mv-card={mvShell ? "" : undefined} className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col", mvShell && "mb-4 mx-4")}>
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */}
{!isMobile && !isEmbedded && (
{!isMobile && !isEmbedded && !mvShell && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
@@ -1050,6 +1067,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
onDeleteContact={handleDeleteContact}
onAddContactToGroup={handleAddContactToGroup}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
isLoading={supportsSync && !contactsLoaded}
/>
</div>
@@ -1,7 +1,8 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { realAttachments, ListAttachmentChips } from "../attachment-chips";
import { render, screen, act, renderHook } from "@testing-library/react";
import { realAttachments, ListAttachmentChips, useListHasAttachment } from "../attachment-chips";
import type { Attachment, Email } from "@/lib/jmap/types";
import type { LoadListAttachments } from "@/lib/list-attachments";
function att(over: Partial<Attachment>): Attachment {
return { partId: "1", blobId: "b1", size: 100, type: "application/pdf", name: "doc.pdf", ...over };
@@ -72,6 +73,41 @@ describe("ListAttachmentChips", () => {
expect(cancel).toHaveBeenCalled();
});
// A virtualised list remounts rows as they scroll back in; one that grew a
// frame after mounting made the list correct the scroll position mid-scroll.
it("draws a cached answer in the first render", () => {
const cached = [att({ name: "cached.pdf" })];
let chipBeforeEffect: Element | null = null;
const load: LoadListAttachments = Object.assign(
(_email: Email, onLoad: (a: Attachment[]) => void) => {
chipBeforeEffect = document.querySelector('[title="cached.pdf"]');
onLoad(cached);
return () => {};
},
{ peek: () => cached },
);
render(<ListAttachmentChips email={row({})} load={load} onOpen={vi.fn()} />);
expect(chipBeforeEffect).toBeTruthy();
expect(screen.getByTitle("cached.pdf")).toBeTruthy();
});
it("drops a peeked answer the loader does not confirm", () => {
const load: LoadListAttachments = Object.assign(vi.fn(() => () => {}), {
peek: () => [att({ name: "other-account.pdf" })],
});
render(<ListAttachmentChips email={row({})} load={load} onOpen={vi.fn()} />);
expect(screen.queryByTitle("other-account.pdf")).toBeNull();
});
it("holds the chip row's space while the parts load", () => {
let deliver!: (a: Attachment[]) => void;
const load = vi.fn((_email: Email, onLoad: (a: Attachment[]) => void) => { deliver = onLoad; return () => {}; });
const { container } = render(<ListAttachmentChips email={row({})} load={load} onOpen={vi.fn()} className="mt-1.5" />);
expect(container.querySelector('[aria-hidden="true"].mt-1\\.5')).toBeTruthy();
act(() => deliver([att({ name: "image001.png", type: "image/png", disposition: "inline" })]));
expect(container.firstChild).toBeNull();
});
it("does not show one message's parts on another", () => {
let deliver!: (a: Attachment[]) => void;
const load = vi.fn((_email: Email, onLoad: (a: Attachment[]) => void) => { deliver = onLoad; return () => {}; });
@@ -82,3 +118,56 @@ describe("ListAttachmentChips", () => {
expect(load).toHaveBeenCalledTimes(2);
});
});
// Stalwart flags a message whose only parts are inline images (a logo in a
// notification mail) with hasAttachment; the viewer then lists nothing.
describe("useListHasAttachment", () => {
const row = (over: Partial<Email>) => ({ id: "m1", hasAttachment: true, ...over }) as Email;
const deferred = () => {
let deliver!: (a: Attachment[]) => void;
const load = vi.fn((_email: Email, onLoad: (a: Attachment[]) => void) => { deliver = onLoad; return () => {}; });
return { load, deliver: (a: Attachment[]) => deliver(a) };
};
it("trusts the server flag until the parts arrive", () => {
const { load } = deferred();
const { result } = renderHook(() => useListHasAttachment(row({}), load));
expect(result.current).toBe(true);
});
it("drops the paperclip when every part is an inline image", () => {
const { load, deliver } = deferred();
const { result } = renderHook(() => useListHasAttachment(row({}), load));
act(() => deliver([
att({ name: "logo.png", type: "image/png", disposition: "inline", cid: "logo" }),
att({ partId: "2", blobId: "b2", name: "github.png", type: "image/png", cid: "gh" }),
]));
expect(result.current).toBe(false);
});
it("keeps the paperclip when a real attachment is among the parts", () => {
const { load, deliver } = deferred();
const { result } = renderHook(() => useListHasAttachment(row({}), load));
act(() => deliver([
att({ name: "logo.png", type: "image/png", disposition: "inline", cid: "logo" }),
att({ partId: "2", blobId: "b2", name: "invoice.pdf" }),
]));
expect(result.current).toBe(true);
});
it("uses parts the email already carries", () => {
const load = vi.fn(() => () => {});
const { result } = renderHook(() => useListHasAttachment(
row({ attachments: [att({ name: "logo.png", type: "image/png", disposition: "inline" })] }), load,
));
expect(result.current).toBe(false);
expect(load).not.toHaveBeenCalled();
});
it("shows nothing for a message the server did not flag", () => {
const load = vi.fn(() => () => {});
const { result } = renderHook(() => useListHasAttachment(row({ hasAttachment: false }), load));
expect(result.current).toBe(false);
expect(load).not.toHaveBeenCalled();
});
});
@@ -83,4 +83,10 @@ describe('compose-format (#1022): HTML <-> plain text switch', () => {
const back = plainComposeBodyToHtml(text, identity, { separator: true });
expect(back).toBe(start);
});
it('adds no separator paragraph in front of a signature that has its own delimiter', () => {
const html = buildEmbeddedSignatureHtml({ textSignature: '-- \nUser A' }, { embed: true, separator: true });
expect(html).not.toContain(`${SIGNATURE_RANGE_MARKER}="separator"`);
expect(count(html, '-- ')).toBe(1);
});
});
@@ -0,0 +1,236 @@
import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { useAuthStore } from '@/stores/auth-store';
// A draft belongs to the account the composer was opened in. The composer
// used to take its client and identities live from the stores, so after an
// account switch with the composer open its saves went to the other
// account - and replacing the previous save deleted that account's message
// with the same id.
// ─── Heavy component mocks (mirrors composer-close-guard.test.tsx) ──────────
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
const updateSetting = vi.fn();
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: (...args: unknown[]) => updateSetting(...args),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Tests ────────────────────────────────────────────────────────────────────
/**
* A host that replaces the compose session - a mailto: click, say - must not
* drop text the user has not sent. It hands a follow-up to requestCloseRef and
* the composer runs it only once it has really closed, so "Save" and "Discard"
* continue into the new session while "Cancel" leaves the draft standing.
*/
const DRAFT = {
to: 'bob@example.com',
cc: '',
bcc: '',
subject: 'Half-written',
body: '<p>Still typing</p>',
showCc: false,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'compose' as const,
draftId: 'draft-7',
};
function client(draftId: string) {
return {
createDraft: vi.fn().mockResolvedValue(draftId),
getEmail: vi.fn().mockResolvedValue({ id: draftId, attachments: [] }),
hasDelayedSend: () => false,
getMaxDelayedSend: () => 0,
};
}
describe('composer across an account switch', () => {
it('keeps saving the draft to the account it was opened in', async () => {
const clientA = client('draft-8');
const clientB = client('draft-9');
useAuthStore.setState({ client: clientA as never, activeAccountId: 'acct-A' as never });
const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
const props = { initialData: DRAFT, requestCloseRef, onClose: vi.fn() };
const view = render(<EmailComposer {...props} />);
fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
// The user switches to account B with the composer still open.
useAuthStore.setState({ client: clientB as never, activeAccountId: 'acct-B' as never });
view.rerender(<EmailComposer {...props} />);
act(() => requestCloseRef.current!(vi.fn()));
const dialog = await screen.findByRole('alertdialog');
fireEvent.click(within(dialog).getByText('save'));
await waitFor(() => expect(clientA.createDraft).toHaveBeenCalled());
expect(clientB.createDraft).not.toHaveBeenCalled();
useAuthStore.setState({ client: null, activeAccountId: null });
});
});
@@ -0,0 +1,280 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { useAuthStore } from '@/stores/auth-store';
import type { FileNode } from '@/lib/jmap/types';
// Attaching files from the Files app (#1179). The picker itself is covered by
// file-picker-dialog.test.tsx; here it is a stub that hands back fixed nodes.
const hoisted = vi.hoisted(() => ({
picked: [] as unknown[],
beforeAttachmentUpload: vi.fn(async (_info: { name: string }) => true),
toastError: vi.fn(),
}));
vi.mock('@/components/files/file-picker-dialog', () => ({
FilePickerDialog: ({ onPick }: { onPick: (nodes: unknown[]) => void }) =>
React.createElement(
'button',
{ type: 'button', 'data-testid': 'fake-file-picker', onClick: () => onPick(hoisted.picked) },
'pick',
),
}));
// ─── Heavy component mocks (mirrors composer-plugin-attachment-source.test.tsx) ─
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: hoisted.toastError, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true },
onBeforeAttachmentUpload: { intercept: hoisted.beforeAttachmentUpload },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
plainTextBodyHasSignature: () => false,
plainTextBodyWithoutSignature: (body: string) => body,
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Tests ────────────────────────────────────────────────────────────────────
function fileNode(id: string, name: string, size: number): FileNode {
return {
id, name, size,
parentId: null,
type: 'application/pdf',
blobId: `blob-${id}`,
created: '2026-10-01T00:00:00Z',
modified: '2026-10-01T00:00:00Z',
};
}
function filesClient(overrides: Record<string, unknown> = {}) {
return {
createDraft: vi.fn().mockResolvedValue('draft-1'),
getEmail: vi.fn(),
hasDelayedSend: () => false,
getMaxDelayedSend: () => 0,
supportsFiles: () => true,
getMaxSizeAttachmentsPerEmail: () => 0,
...overrides,
};
}
function pickFromFiles(nodes: FileNode[]) {
hoisted.picked = nodes;
fireEvent.click(screen.getByTestId('composer-attach-menu'));
fireEvent.click(screen.getByTestId('composer-attach-from-files'));
fireEvent.click(screen.getByTestId('fake-file-picker'));
}
describe('attach from Files', () => {
afterEach(() => {
useAuthStore.setState({ client: null });
vi.clearAllMocks();
hoisted.beforeAttachmentUpload.mockImplementation(async () => true);
});
it('only offers the button when the account has Files', () => {
useAuthStore.setState({ client: filesClient({ supportsFiles: () => false }) as never });
render(<EmailComposer onClose={vi.fn()} />);
expect(screen.queryByTestId('composer-attach-from-files')).toBeNull();
expect(screen.queryByTestId('composer-attach-menu')).toBeNull();
});
it('attaches the picked files by their blobId and saves them with the draft', async () => {
const client = filesClient();
useAuthStore.setState({ client: client as never });
render(<EmailComposer onClose={vi.fn()} />);
pickFromFiles([fileNode('n1', 'report.pdf', 1200)]);
expect(await screen.findByText('report.pdf')).toBeInTheDocument();
expect(screen.queryByTestId('fake-file-picker')).toBeNull();
vi.useFakeTimers();
fireEvent.change(screen.getByPlaceholderText(/subject/i), { target: { value: 'The report' } });
await act(async () => { await vi.advanceTimersByTimeAsync(2500); });
expect(client.createDraft).toHaveBeenCalledTimes(1);
expect(client.createDraft.mock.calls[0][8]).toEqual([
expect.objectContaining({ blobId: 'blob-n1', name: 'report.pdf', type: 'application/pdf', size: 1200 }),
]);
vi.useRealTimers();
});
it('leaves out a file a plugin refuses', async () => {
hoisted.beforeAttachmentUpload.mockImplementation(async (info) => info.name !== 'setup.exe');
useAuthStore.setState({ client: filesClient() as never });
render(<EmailComposer onClose={vi.fn()} />);
pickFromFiles([fileNode('n1', 'setup.exe', 10), fileNode('n2', 'notes.pdf', 10)]);
expect(await screen.findByText('notes.pdf')).toBeInTheDocument();
expect(screen.queryByText('setup.exe')).toBeNull();
});
it('stops at the per-message size limit and says so', async () => {
useAuthStore.setState({ client: filesClient({ getMaxSizeAttachmentsPerEmail: () => 1500 }) as never });
render(<EmailComposer onClose={vi.fn()} />);
pickFromFiles([fileNode('n1', 'small.pdf', 1000), fileNode('n2', 'big.pdf', 1000)]);
expect(await screen.findByText('small.pdf')).toBeInTheDocument();
expect(screen.queryByText('big.pdf')).toBeNull();
expect(hoisted.toastError).toHaveBeenCalledWith('attachments_total_too_large');
});
});
@@ -0,0 +1,272 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
// ─── Heavy component mocks (mirrors empty-subject-warning.test.tsx) ────────────
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
const updateSetting = vi.fn();
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: (...args: unknown[]) => updateSetting(...args),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeEmailSend: { intercept: async () => true },
onComposeSend: { intercept: async () => true },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Layout stubs ───────────────────────────────────────────────────────────────
// jsdom has no layout and no ResizeObserver. Record what each observer
// watches so a test can fire the composer's own observer after setting the
// heights it reads.
const observers: { callback: ResizeObserverCallback; targets: Element[] }[] = [];
class ResizeObserverStub {
private entry: { callback: ResizeObserverCallback; targets: Element[] };
constructor(callback: ResizeObserverCallback) {
this.entry = { callback, targets: [] };
observers.push(this.entry);
}
observe(target: Element) { this.entry.targets.push(target); }
unobserve() {}
disconnect() { this.entry.targets = []; }
}
const ATTACHMENTS = Array.from({ length: 12 }, (_, i) => ({
blobId: `blob-${i}`,
name: `invoice-${i}.pdf`,
type: 'application/pdf',
size: 1024 * (i + 1),
}));
const DRAFT = {
to: 'bob@example.com',
cc: '',
bcc: '',
subject: 'Invoices',
body: '<p>see attached</p>',
showCc: false,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
attachments: ATTACHMENTS,
};
/** The wrapping flex row that holds every chip and the expand button. */
function chipRow() {
return screen.getByText('invoice-0.pdf').closest('[class*="flex-wrap"]') as HTMLElement;
}
/**
* The attachment row is a shrink-0 sibling of the body's scroll container,
* so whatever height it takes is taken from the composer and from nothing
* else. Collapsed that is three chips. Expanded it is one row per two
* attachments, and with a few dozen files it grew past the bottom of the
* composer: the send toolbar was pushed off-screen and no ancestor
* scrolled, so neither the rest of the list nor Send could be reached.
* Expanding therefore has to bound the row and scroll it itself.
*/
describe('expanded attachment list', () => {
beforeEach(() => {
observers.length = 0;
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('does not scroll while collapsed', () => {
render(<EmailComposer initialData={DRAFT} />);
expect(chipRow().className).not.toContain('overflow-y-auto');
});
it('shows only the first three attachments and a counter for the rest', () => {
render(<EmailComposer initialData={DRAFT} />);
expect(screen.getByText('invoice-2.pdf')).toBeInTheDocument();
expect(screen.queryByText('invoice-3.pdf')).not.toBeInTheDocument();
expect(screen.getByText(`+${ATTACHMENTS.length - 3}`)).toBeInTheDocument();
});
it('caps its height and scrolls once expanded', () => {
render(<EmailComposer initialData={DRAFT} />);
fireEvent.click(screen.getByText(`+${ATTACHMENTS.length - 3}`));
const row = chipRow();
expect(screen.getByText('invoice-11.pdf')).toBeInTheDocument();
expect(row.className).toContain('overflow-y-auto');
// Bounded, or the row grows with the attachment count again and takes
// the send toolbar off the bottom of the composer with it.
expect(row.className).toContain('max-h-[');
});
it('stops scrolling again when collapsed back', () => {
render(<EmailComposer initialData={DRAFT} />);
fireEvent.click(screen.getByText(`+${ATTACHMENTS.length - 3}`));
fireEvent.click(screen.getByText('show_less'));
expect(chipRow().className).not.toContain('overflow-y-auto');
});
});
@@ -142,7 +142,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true },
onComposeSend: { intercept: async () => true },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
@@ -256,6 +256,43 @@ describe('composer close guard with a follow-up', () => {
expect(afterClose).not.toHaveBeenCalled();
});
it('closes without asking once autosave has stored every change', async () => {
const createDraft = vi.fn().mockResolvedValue('draft-9');
useAuthStore.setState({
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
});
try {
const { requestCloseRef, afterClose } = renderWithCloseRef();
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
await act(async () => {});
act(() => requestCloseRef.current!(afterClose));
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
} finally {
useAuthStore.setState({ client: null });
}
});
it('still asks when there are edits since the last autosave', async () => {
const createDraft = vi.fn().mockResolvedValue('draft-9');
useAuthStore.setState({
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
});
try {
const { requestCloseRef, afterClose } = renderWithCloseRef();
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } });
act(() => requestCloseRef.current!(afterClose));
expect(await screen.findByText('close_draft_title')).toBeInTheDocument();
} finally {
useAuthStore.setState({ client: null });
}
});
it('closes straight through when there is nothing to lose', async () => {
const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
const afterClose = vi.fn();

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