diff --git a/.dockerignore b/.dockerignore
index bd372f65..8ff4689c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -34,5 +34,4 @@ db.sqlite3
# Frontend
node_modules
-out
-.next
+dist
diff --git a/.github/workflows/messages.yml b/.github/workflows/messages.yml
index 7fd918b7..ef99f3fa 100644
--- a/.github/workflows/messages.yml
+++ b/.github/workflows/messages.yml
@@ -8,6 +8,11 @@ name: Lint and tests
branches:
- '*'
+# Default to least-privilege at the workflow scope. Jobs that need
+# more (e.g. opening / editing comments) opt in explicitly.
+permissions:
+ contents: read
+
env:
COMPOSE_BAKE: true
@@ -24,6 +29,17 @@ jobs:
run: make lint-back
+ typecheck-jmap-email:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ - name: Create env files
+ run: make create-env-files
+ - name: Run pyright (strict) on the jmap-email library
+ run: make typecheck-jmap-email
+
+
test-back:
runs-on: ubuntu-latest
env:
diff --git a/.gitignore b/.gitignore
index 306f7533..cb7dbaa3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,8 +14,8 @@ dist/
downloads/
eggs/
.eggs/
-lib/
-lib64/
+/lib/
+/lib64/
parts/
sdist/
var/
@@ -27,7 +27,6 @@ share/python-wheels/
*.egg
MANIFEST
.DS_Store
-.next/
# Translations # Translations
*.pot
@@ -46,12 +45,12 @@ env.d/terraform
# npm
node_modules
-src/frontend/out/
+src/frontend/dist/
tsconfig.tsbuildinfo
src/frontend/.config
-src/frontend/.next
-src/frontend/next-env.d.ts
+src/frontend/src/routes.gen.ts
.vite
+.tanstack
# Mails
src/backend/core/templates/mail/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 163d03d3..b428ec00 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,132 @@ and this project adheres to
## [Unreleased]
+- Bump keycloak to 26.6.3
+
+## [0.8.0] - 2026-06-18
+
+### Added
+
+- Allow permanently deleting drafts and improve draft edition
+- Allow passwordless mailbox creation when identity sync is off #707
+- Gather mailbox settings into a dialog
+- Translate template placeholder and add `user_name` builtin variable
+- Report selfcheck status to Sentry crons #694
+
+### Changed
+
+- Drop Next.js for Vite + TanStack Router #675
+- Move email parser & composer to new `jmap-email` library #700
+- Add PyPI release scripts for `jmap-email`
+- Use `LaGaufreV2` component
+- Improve thread navigation a11y and multiselect UX #708
+- Refine mailbox dropdown menu #705
+- New homepage illustration #702
+- Internationalize missing strings
+- Wrap autoreply date column
+- Bump `dompurify` to 3.4.11
+- Bump `django-lasuite` to 0.0.26 #689
+
+### Fixed
+
+- Fix composer issues
+- Add `To` header to outbound mails missing one #712
+- Manage message/delivery-status attachments at compose
+- Persist mailbox name when contact is missing
+- Fix order and default calendar selection when RSVPing #699
+- Fix display of recurring events with exceptions #686
+- Fix opportunistic TLS against MXes with mismatched certs #687
+- Fix mbox detection as `text/html` with some libmagic versions #696
+- Complete PST email folder prefixes list
+- Fix milter socket permission race on startup #693
+
+### Security
+
+- Add some defense-in-depth bits #706
+- Harden SMTP connection & proxies config
+- Harden inbound email parsing #695
+
+## [0.7.0] - 2026-05-28
+
+### Added
+
+- Attachments preview #676
+- Add link to a CalDAV instance to accept events directly #584
+
+### Changed
+
+- Improve sending experience #681
+- Remove deprecated model fields from tiered storage migration #678
+
+### Fixed
+
+- Unmount thread view immediately on unselect thread #680
+- Prevent refetch thread messages on draft deletion #682
+
+## [0.6.0] - 2026-05-20
+
+### Added
+
+- Add thread assignation feature #645
+- Add mention notifications via UserEvent #621
+- Allow sending internal messages through ThreadEvent #566
+- Add thread deep linking #664
+- Add label assignment with archive and bulk label widget
+- Enable inviting users that haven't logged in yet #644
+- Add configurable inbound auth backends #636
+- Add encryption, custom scopes, levels and auditing on channels #599
+- Add recursive SPF check and optional send-time validation #625
+- Add tiered storage and refactor blobs/attachments
+- Add mandatory TOTP field and search field in admin #667
+- Add silent login support
+- Make panel sections resizable
+- Add read/unread action on thread action bar #659
+- Add lprobe healthchecks and checksum verification for lprobe + Caddy #600
+
+### Changed
+
+- Improve message composer
+- Switch back to Python's stdlib for email composition
+- Put split thread feature behind a feature flag #624
+- Show tooltip to confirm mailbox refresh
+- Disable application menu when no option is available
+- Focus `to` field on forward
+- Align send button on the left
+- Upgrade Cunningham and ui-kit
+- Localize attachment separator
+- Force default language on frontend #647
+- Add DNS propagation delay info #654
+- Allow specifying a channel id for the home feedback widget #655
+- Support legacy and new widget attribute #650
+- Update widget logic to latest version #649
+- Refactor thread query cache management #642
+- Allow reindexing from a given date
+- Defer indexation tasks for better throughput
+- Improve `search_reindex` bulk payload
+- Move imports and reindex worker queues to dedicated containers #643
+- Bump Keycloak to 26.6.1 #637
+
+### Fixed
+
+- Improve PST import logic
+- Allow thread editor to destroy thread accesses #668
+- Enforce full edit rights on thread mutations
+- Fix race condition in last-editor deletion guard
+- Fix thread panel header with nested label #658
+- Fix label popup stacking with create-label modal #635
+- Fix email parsing edge cases with UTF-8 in flanker #656
+- Fix threads ordering #617
+- Fix double request and flickering on search #596
+- Handle non-serializable Celery task errors and stop infinite polling #633
+- Quote error field and log SOCKS proxy in outbound delivery #626
+- Do not mark thread as read when sending autoreply #594
+
+### Security
+
+- Stop flagging inbound `From=To` mails as `is_sender` #652
+- Force including special characters in generated passwords #640
+- Factorize SSRF code and allow redirects in image proxy #631
+
## [0.5.0] - 2026-03-16
### Added
@@ -165,7 +291,10 @@ and this project adheres to
- Exclude `is_trashed` and `is_spam` threads from search results by default
- `to` search modifier now looks for messages where recipient fields (to, cc, bcc) contain the given email address.
-[unreleased]: https://github.com/suitenumerique/messages/compare/v0.5.0...main
+[unreleased]: https://github.com/suitenumerique/messages/compare/v0.8.0...main
+[0.8.0]: https://github.com/suitenumerique/messages/compare/v0.7.0...v0.8.0
+[0.7.0]: https://github.com/suitenumerique/messages/compare/v0.6.0...v0.7.0
+[0.6.0]: https://github.com/suitenumerique/messages/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/suitenumerique/messages/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/suitenumerique/messages/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/suitenumerique/messages/compare/v0.2.0...v0.3.0
diff --git a/Makefile b/Makefile
index 74a6e615..fc6e4864 100644
--- a/Makefile
+++ b/Makefile
@@ -115,7 +115,9 @@ build: ## build the project containers
.PHONY: build
build-back-distroless: ## build the distroless production image
- @docker build --target runtime-distroless-prod -t messages-distroless -f src/backend/Dockerfile src/backend/
+ @docker buildx build --load --target runtime-distroless-prod -t messages-distroless \
+ -f src/backend/Dockerfile \
+ src/backend/
.PHONY: build-back-distroless
test-back-distroless: build-back-distroless ## build and smoke-test the distroless production image
@@ -227,6 +229,10 @@ analyze-back: ## analyze back-end python sources
@$(COMPOSE_RUN_APP_TOOLS) sh -c "pylint ."
.PHONY: analyze-back
+analyze-front: ## analyze frontend bundle sizes (per-chunk + per-package breakdown)
+ @$(COMPOSE) run --rm frontend-tools npm run analyze
+.PHONY: analyze-front
+
typecheck-front: ## run the frontend type checker
@$(COMPOSE) run --rm frontend-tools npm run ts:check
.PHONY: typecheck-front
@@ -314,6 +320,28 @@ test-mpa: ## run the mpa tests
@$(COMPOSE) run --build --rm mpa-test
.PHONY: test-mpa
+test-jmap-email: ## run the jmap-email package tests (zero infrastructure deps)
+ @$(COMPOSE) run --build --rm jmap-email-test
+.PHONY: test-jmap-email
+
+fuzz-jmap-email: ## run the jmap-email Hypothesis fuzz suite
+ @$(COMPOSE) run --build --rm jmap-email-test pytest -m fuzz tests/
+.PHONY: fuzz-jmap-email
+
+lint-jmap-email: ## lint the jmap-email library (ruff check + format check + pylint)
+ @$(COMPOSE) run --build --rm --entrypoint ruff jmap-email-test check jmap_email tests
+ @$(COMPOSE) run --build --rm --entrypoint ruff jmap-email-test format --check jmap_email tests
+ @$(COMPOSE) run --build --rm --entrypoint pylint jmap-email-test jmap_email tests
+.PHONY: lint-jmap-email
+
+typecheck-jmap-email: ## type-check the jmap-email library with ty (Astral, Rust)
+ @$(COMPOSE) run --build --rm --entrypoint ty jmap-email-test check
+.PHONY: typecheck-jmap-email
+
+release-jmap-email: ## publish jmap-email to PyPI (interactive: TestPyPI โ smoke install โ PyPI)
+ @bin/release-jmap-email.sh
+.PHONY: release-jmap-email
+
test-socks-proxy: ## run the socks-proxy tests
@$(COMPOSE) run --build --rm socks-proxy-test
.PHONY: test-socks-proxy
diff --git a/README.md b/README.md
index 4db1d52f..b3cea329 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ Messages is the all-in-one collaborative inbox for [La Suite territoriale](https
Messages is a full communication platform enabling teams to collaborate on emails through shared or personal mailboxes.
-It features a [MTA](https://en.wikipedia.org/wiki/Message_transfer_agent) based on [Postfix](https://www.postfix.org/), a custom [MDA](https://en.wikipedia.org/wiki/Message_delivery_agent) built on top of [Django Rest Framework](https://www.django-rest-framework.org/) and a frontend using [Next.js](https://nextjs.org/) and [BlockNote.js](https://www.blocknotejs.org/).
+It features a [MTA](https://en.wikipedia.org/wiki/Message_transfer_agent) based on [Postfix](https://www.postfix.org/), a custom [MDA](https://en.wikipedia.org/wiki/Message_delivery_agent) built on top of [Django Rest Framework](https://www.django-rest-framework.org/) and a frontend using [Vite](https://vite.dev/), [TanStack Router](https://tanstack.com/router/) and [BlockNote.js](https://www.blocknotejs.org/).
### Familiar messaging features
* ๐ Receive, draft and send emails.
diff --git a/bin/release-jmap-email.sh b/bin/release-jmap-email.sh
new file mode 100755
index 00000000..d2779adb
--- /dev/null
+++ b/bin/release-jmap-email.sh
@@ -0,0 +1,350 @@
+#!/usr/bin/env bash
+#
+# Interactive PyPI release for the jmap-email package.
+#
+# Hermetic: every step runs inside the python:3.14.5-slim image, so a
+# clean VM only needs Docker. The host never touches pip or twine.
+#
+# Flow:
+# 1. Run ``make lint-jmap-email typecheck-jmap-email test-jmap-email``
+# 2. Build sdist + wheel inside Docker, run ``twine check``
+# 3. Auto-inspect wheel/sdist contents and METADATA fields
+# 4. Prompt for TestPyPI API token, upload, smoke-install in a
+# throwaway container
+# 5. Prompt for PyPI API token, upload
+#
+# Each gate is interactive (y/N). Bail out anytime with Ctrl-C.
+#
+# Set ``SKIP_GATES=1`` to skip lint/typecheck/tests on retry.
+
+set -eo pipefail
+
+REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PKG_DIR="${REPO_DIR}/src/jmap-email"
+PYTHON_IMAGE="python:3.14.5-slim"
+
+BOLD=$'\033[1m'
+GREEN=$'\033[1;32m'
+BLUE=$'\033[1;34m'
+RED=$'\033[1;31m'
+YELLOW=$'\033[1;33m'
+RESET=$'\033[0m'
+
+say() { printf '%s%s%s\n' "${BLUE}" "$*" "${RESET}"; }
+ok() { printf '%sโ %s%s\n' "${GREEN}" "$*" "${RESET}"; }
+warn() { printf '%s%s%s\n' "${YELLOW}" "$*" "${RESET}"; }
+die() { printf '%sโ %s%s\n' "${RED}" "$*" "${RESET}" >&2; exit 1; }
+
+confirm() {
+ local prompt="$1"
+ local ans
+ read -r -p "${BOLD}${prompt} [y/N]${RESET} " ans
+ [[ "${ans}" =~ ^[Yy]$ ]] || die "aborted"
+}
+
+read_token() {
+ # $1 = label, $2 = name of variable to assign into
+ local label="$1" varname="$2" token
+ read -r -s -p "${BOLD}${label} API token (pypi-โฆ):${RESET} " token
+ echo
+ [[ -n "${token}" ]] || die "empty token"
+ [[ "${token}" == pypi-* ]] || warn "token does not start with 'pypi-' โ continuing anyway"
+ printf -v "${varname}" '%s' "${token}"
+}
+
+# โโ pre-flight โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+command -v docker >/dev/null || die "docker not found in PATH"
+[[ -f "${PKG_DIR}/pyproject.toml" ]] || die "no pyproject.toml at ${PKG_DIR}"
+
+VERSION="$(awk -F'"' '/^version = /{print $2; exit}' "${PKG_DIR}/pyproject.toml")"
+[[ -n "${VERSION}" ]] || die "could not read version from pyproject.toml"
+
+printf '\n%sโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ%s\n' "${BLUE}" "${RESET}"
+printf '%s Release jmap-email %s%s\n' "${BLUE}" "${VERSION}" "${RESET}"
+printf '%sโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ%s\n\n' "${BLUE}" "${RESET}"
+say "Image: ${PYTHON_IMAGE}"
+say "Package: ${PKG_DIR}"
+say "Flow: lint+typecheck+tests โ build โ inspect โ TestPyPI โ smoke install โ PyPI"
+[[ "${SKIP_GATES:-0}" == "1" ]] && warn "SKIP_GATES=1 โ lint/typecheck/tests will be skipped"
+echo
+confirm "Proceed?"
+
+# โโ 1. lint, typecheck, tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+if [[ "${SKIP_GATES:-0}" == "1" ]]; then
+ warn "skipping lint/typecheck/tests (SKIP_GATES=1)"
+else
+ say "โ make lint-jmap-email"
+ make -C "${REPO_DIR}" lint-jmap-email
+
+ say "โ make typecheck-jmap-email"
+ make -C "${REPO_DIR}" typecheck-jmap-email
+
+ say "โ make test-jmap-email"
+ make -C "${REPO_DIR}" test-jmap-email
+
+ ok "Lint + typecheck + tests passed"
+fi
+
+# โโ 2. build + check โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+say "โ Cleaning previous artifacts"
+rm -rf "${PKG_DIR}/dist" "${PKG_DIR}/build"
+
+say "โ Building sdist + wheel inside ${PYTHON_IMAGE}"
+docker run --rm -t \
+ --user "$(id -u):$(id -g)" \
+ -v "${PKG_DIR}:/pkg" \
+ -w /pkg \
+ -e HOME=/tmp \
+ "${PYTHON_IMAGE}" \
+ bash -c '
+ set -eo pipefail
+ pip install --quiet --no-cache-dir --root-user-action=ignore --target /tmp/pip build twine
+ export PYTHONPATH=/tmp/pip
+ python -m build --outdir dist
+ python -m twine check dist/*
+ '
+
+echo
+ls -lh "${PKG_DIR}/dist/"
+echo
+ok "Build + twine check passed"
+
+# โโ 3. inspect artifact contents โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+say "โ Inspecting wheel + sdist contents and METADATA"
+docker run --rm -i \
+ -v "${PKG_DIR}/dist:/dist:ro" \
+ "${PYTHON_IMAGE}" \
+ python - "${VERSION}" <<'PYEOF'
+import re
+import sys
+import tarfile
+import zipfile
+from pathlib import Path
+
+VERSION = sys.argv[1]
+DIST = Path("/dist")
+WHEEL = DIST / f"jmap_email-{VERSION}-py3-none-any.whl"
+SDIST = DIST / f"jmap_email-{VERSION}.tar.gz"
+
+errors, warnings = [], []
+
+# โโ wheel: file list โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+if not WHEEL.exists():
+ print(f"FATAL: wheel missing: {WHEEL}")
+ sys.exit(1)
+with zipfile.ZipFile(WHEEL) as zf:
+ wheel_names = set(zf.namelist())
+ metadata = zf.read(f"jmap_email-{VERSION}.dist-info/METADATA").decode()
+ wheel_file_count = sum(1 for n in wheel_names if not n.endswith("/"))
+
+expected_wheel = {
+ "jmap_email/__init__.py",
+ "jmap_email/composer.py",
+ "jmap_email/helpers.py",
+ "jmap_email/limits.py",
+ "jmap_email/parser.py",
+ "jmap_email/types.py",
+ "jmap_email/py.typed",
+ f"jmap_email-{VERSION}.dist-info/METADATA",
+ f"jmap_email-{VERSION}.dist-info/WHEEL",
+ f"jmap_email-{VERSION}.dist-info/RECORD",
+}
+missing = expected_wheel - wheel_names
+if missing:
+ errors.append(f"wheel missing required files: {sorted(missing)}")
+
+forbidden = [
+ ("tests/", lambda n: n.startswith("tests/")),
+ ("examples/", lambda n: n.startswith("examples/")),
+ (".pyc files", lambda n: n.endswith(".pyc")),
+ ("__pycache__", lambda n: "__pycache__" in n),
+ ("Dockerfile", lambda n: n.endswith("Dockerfile")),
+ (".pytest_cache",lambda n: ".pytest_cache" in n),
+]
+for label, pred in forbidden:
+ bad = [n for n in wheel_names if pred(n)]
+ if bad:
+ errors.append(f"wheel contains forbidden {label}: {bad[:3]}")
+
+if not any("LICENSE" in n for n in wheel_names):
+ errors.append("wheel does not bundle LICENSE")
+
+# โโ wheel: METADATA โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+def meta(key):
+ m = re.search(rf"^{re.escape(key)}: (.+)$", metadata, re.MULTILINE)
+ return m.group(1).strip() if m else None
+
+if meta("Name") != "jmap-email":
+ errors.append(f"METADATA Name: expected 'jmap-email', got {meta('Name')!r}")
+if meta("Version") != VERSION:
+ errors.append(f"METADATA Version: expected {VERSION!r}, got {meta('Version')!r}")
+
+rp = meta("Requires-Python")
+if not rp or "3.14" not in rp:
+ errors.append(f"METADATA Requires-Python missing or not 3.14+: {rp!r}")
+
+# Modern PEP 639 uses License-Expression; older hatchling emits License.
+if not (meta("License-Expression") or meta("License")):
+ errors.append("METADATA has no License or License-Expression")
+
+dct = meta("Description-Content-Type")
+if not dct or "markdown" not in dct.lower():
+ errors.append(f"METADATA Description-Content-Type isn't markdown: {dct!r}")
+
+if "# jmap-email" not in metadata:
+ warnings.append("METADATA description doesn't contain '# jmap-email' โ README may not have been embedded")
+
+for url in ("Homepage", "Repository"):
+ if f"Project-URL: {url}," not in metadata:
+ warnings.append(f"METADATA missing Project-URL: {url}")
+
+if "Topic :: Communications :: Email" not in metadata:
+ warnings.append("METADATA missing 'Topic :: Communications :: Email' classifier")
+
+# โโ sdist โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+if not SDIST.exists():
+ print(f"FATAL: sdist missing: {SDIST}")
+ sys.exit(1)
+with tarfile.open(SDIST) as tf:
+ sdist_names = tf.getnames()
+
+prefix = f"jmap_email-{VERSION}/"
+sdist_rel = {n[len(prefix):] for n in sdist_names if n.startswith(prefix)}
+
+expected_sdist = {
+ "pyproject.toml",
+ "README.md",
+ "LICENSE",
+ "CHANGELOG.md",
+ "PKG-INFO",
+ "jmap_email/__init__.py",
+ "jmap_email/parser.py",
+ "jmap_email/composer.py",
+ "jmap_email/py.typed",
+}
+missing_sdist = expected_sdist - sdist_rel
+if missing_sdist:
+ errors.append(f"sdist missing required files: {sorted(missing_sdist)}")
+
+if not any(n.startswith("tests/") and n.endswith(".py") for n in sdist_rel):
+ warnings.append("sdist contains no tests/*.py โ pyproject.toml asked for tests/**/*.py")
+if not any(n.startswith("examples/") and n.endswith(".py") for n in sdist_rel):
+ warnings.append("sdist contains no examples/*.py โ pyproject.toml asked for examples/**/*.py")
+
+bad_sdist = [n for n in sdist_rel if n.endswith(".pyc") or "__pycache__" in n]
+if bad_sdist:
+ errors.append(f"sdist contains bytecode: {bad_sdist[:3]}")
+
+# โโ report โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+print()
+print(f" Wheel: {WHEEL.name} ({WHEEL.stat().st_size // 1024} KB, {wheel_file_count} files)")
+print(f" Sdist: {SDIST.name} ({SDIST.stat().st_size // 1024} KB, {len(sdist_rel)} entries)")
+print()
+print(" METADATA highlights:")
+for key in ("Name", "Version", "Requires-Python",
+ "License-Expression", "License", "Description-Content-Type"):
+ v = meta(key)
+ if v:
+ print(f" {key:28s} {v}")
+print()
+
+if warnings:
+ print(" โ Warnings:")
+ for w in warnings:
+ print(f" {w}")
+ print()
+
+if errors:
+ print(" โ Errors:")
+ for e in errors:
+ print(f" {e}")
+ print()
+ sys.exit(1)
+
+print(" โ All artifact checks passed")
+PYEOF
+
+echo
+confirm "Artifacts look right?"
+
+# โโ 4. TestPyPI โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+say "โ TestPyPI upload"
+echo "Get a token at https://test.pypi.org/manage/account/token/"
+echo "(Account-scoped on first release; project-scoped after.)"
+read_token "TestPyPI" TESTPYPI_TOKEN
+
+docker run --rm -t \
+ --user "$(id -u):$(id -g)" \
+ -v "${PKG_DIR}:/pkg" \
+ -w /pkg \
+ -e HOME=/tmp \
+ -e TWINE_USERNAME=__token__ \
+ -e TWINE_PASSWORD="${TESTPYPI_TOKEN}" \
+ "${PYTHON_IMAGE}" \
+ bash -c '
+ set -eo pipefail
+ pip install --quiet --no-cache-dir --root-user-action=ignore --target /tmp/pip twine
+ export PYTHONPATH=/tmp/pip
+ python -m twine upload --repository-url https://test.pypi.org/legacy/ dist/*
+ '
+ok "Uploaded to TestPyPI"
+echo " https://test.pypi.org/project/jmap-email/${VERSION}/"
+
+# โโ 5. smoke install โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+say "โ Smoke-installing jmap-email==${VERSION} from TestPyPI"
+# TestPyPI's index has limited transitive coverage; jmap-email has zero
+# runtime deps so a bare TestPyPI install is fine. The retry loop covers
+# the index-propagation lag (~30s after upload).
+docker run --rm -t \
+ "${PYTHON_IMAGE}" \
+ bash -c "
+ set -eo pipefail
+ for i in 1 2 3 4 5; do
+ if pip install --quiet --no-cache-dir \
+ --index-url https://test.pypi.org/simple/ \
+ jmap-email==${VERSION}; then
+ break
+ fi
+ echo 'index not yet propagated, retrying in 10sโฆ'
+ sleep 10
+ done
+ python -c '
+import jmap_email
+assert jmap_email.__version__ == \"${VERSION}\", jmap_email.__version__
+e = jmap_email.parse_email(b\"From: a@b\r\nSubject: t\r\n\r\nhi\")
+assert e is not None and e[\"subject\"] == \"t\"
+print(\"smoke-install ok โ version\", jmap_email.__version__)
+'
+ "
+ok "Smoke install passed"
+
+# โโ 6. real PyPI โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+echo
+warn "Next step is irreversible: PyPI version numbers cannot be reused."
+confirm "Publish jmap-email ${VERSION} to real PyPI?"
+
+say "โ PyPI upload"
+echo "Get a token at https://pypi.org/manage/account/token/"
+read_token "PyPI" PYPI_TOKEN
+
+docker run --rm -t \
+ --user "$(id -u):$(id -g)" \
+ -v "${PKG_DIR}:/pkg" \
+ -w /pkg \
+ -e HOME=/tmp \
+ -e TWINE_USERNAME=__token__ \
+ -e TWINE_PASSWORD="${PYPI_TOKEN}" \
+ "${PYTHON_IMAGE}" \
+ bash -c '
+ set -eo pipefail
+ pip install --quiet --no-cache-dir --root-user-action=ignore --target /tmp/pip twine
+ export PYTHONPATH=/tmp/pip
+ python -m twine upload dist/*
+ '
+
+echo
+ok "jmap-email ${VERSION} released to PyPI"
+echo " https://pypi.org/project/jmap-email/${VERSION}/"
+echo
+echo "Next: tag the release in git when you're ready:"
+echo " git tag jmap-email-${VERSION} && git push origin jmap-email-${VERSION}"
diff --git a/bin/scalingo_postfrontend b/bin/scalingo_postfrontend
index a1571236..a72d9d98 100644
--- a/bin/scalingo_postfrontend
+++ b/bin/scalingo_postfrontend
@@ -7,7 +7,7 @@ echo "-----> Running post-frontend script"
# Move the frontend build to the app root and clean up
mkdir -p build/
-mv src/frontend/out build/frontend-out
+mv src/frontend/dist build/frontend-out
mv src/backend/* ./
mkdir -p messages_backend && touch messages_backend/__init__.py
diff --git a/bin/scalingo_run_web b/bin/scalingo_run_web
index 836d216f..6f0bc4d8 100644
--- a/bin/scalingo_run_web
+++ b/bin/scalingo_run_web
@@ -11,10 +11,10 @@ gunicorn -b :8000 messages.wsgi:application --log-file - &
# Start the Caddy server
bin/caddy run --config Caddyfile --adapter caddyfile &
-# if the current shell is killed, also terminate all its children
-trap "pkill SIGTERM -P $$" SIGTERM
-
-# wait for a single child to finish,
-wait -n
-# then kill all the other tasks
+# if the current shell is killed, also terminate all its children
+trap "pkill SIGTERM -P $$" SIGTERM
+
+# wait for a single child to finish,
+wait -n
+# then kill all the other tasks
pkill -P $$
diff --git a/compose.yaml b/compose.yaml
index d7e5c2ff..2408f8aa 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -90,6 +90,14 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
+ # Dev-only override: live-mount the jmap-email working tree over the
+ # package installed from PyPI so local source edits propagate without a
+ # rebuild. The wheel installed at
+ # ``/venv/lib/${PYTHON_VERSION}/site-packages/jmap_email`` is overlaid
+ # with the working-tree source. Override ``PYTHON_VERSION`` in the
+ # environment when the backend's Python floor moves. Comment this mount
+ # out to run exactly what CI/prod install from PyPI.
+ - ./src/jmap-email/jmap_email:/venv/lib/${PYTHON_VERSION:-python3.14}/site-packages/jmap_email
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request as u; u.urlopen('http://localhost:8000/__heartbeat__/', timeout=1)"]
interval: 3s
@@ -143,7 +151,7 @@ services:
volumes:
- ./src/backend:/app
build:
- context: src/backend/
+ context: src/backend
target: uv
pull_policy: build
@@ -401,7 +409,7 @@ services:
pull_policy: build
keycloak:
- image: quay.io/keycloak/keycloak:26.6.1
+ image: quay.io/keycloak/keycloak:26.6.3
volumes:
- ./src/keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro
- ./src/keycloak/themes/dsfr-2.2.1.jar:/opt/keycloak/providers/keycloak-theme.jar:ro
@@ -455,5 +463,20 @@ services:
redis:
condition: service_started
+ # Self-contained jmap-email package tests. Zero infrastructure
+ # dependencies (no DB, no opensearch, no redis) โ the library has
+ # no runtime deps. Source is mounted for instant feedback during
+ # development.
+ jmap-email-test:
+ profiles:
+ - tools
+ build:
+ context: src/jmap-email
+ command: pytest -q tests/
+ volumes:
+ - ./src/jmap-email/jmap_email:/app/jmap_email
+ - ./src/jmap-email/tests:/app/tests
+ - ./src/jmap-email/pyproject.toml:/app/pyproject.toml
+
volumes:
objectstorage-data:
diff --git a/docs/architecture.md b/docs/architecture.md
index 6c27d59b..e9c8964e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -8,7 +8,7 @@
### Frontend App
-- **Next.js Application**: React-based SPA with TypeScript
+- **React Application**: React-based SPA with TypeScript, Vite, Tanstack Router and React Query
- **Auto-generated API Client**: Generated from OpenAPI schema using Orval
- **Multi-panel Interface**: Mailbox panel, thread list, and message view
- **Real-time Updates**: Using TanStack Query for efficient state management
diff --git a/docs/env.md b/docs/env.md
index f943baaf..38a7b745 100644
--- a/docs/env.md
+++ b/docs/env.md
@@ -93,7 +93,7 @@ The application uses a new environment file structure with `.defaults` and `.loc
| `MTA_OUT_RELAY_PASSWORD` | `pass` | Outbound SMTP password for relay mode | Optional |
| `MTA_OUT_DIRECT_PROXIES` | `[]` | List of SOCKS proxy URLs (randomly chosen when non-empty; used in direct mode) | Optional |
| `MTA_OUT_DIRECT_PORT` | `25` | TCP port for direct mode on remote MX servers | Optional |
-| `MTA_OUT_SMTP_TLS_SECURITY_LEVEL` | `may` | SMTP TLS security level ("none", "may") | Optional |
+| `MTA_OUT_SMTP_TLS_SECURITY_LEVEL` | `may` | SMTP TLS security level: `none`, `may` (opportunistic, no cert check, matches Postfix), or `secure` (mandatory TLS + CA chain + hostname check). Applied to both direct and relay modes โ set to `secure` when running against a controlled relay with a valid cert. | Optional |
| `MDA_API_SECRET` | `my-shared-secret-mda` | Shared secret for MDA API | Required |
| `MDA_API_BASE_URL` | `http://backend-dev:8000/api/v1.0/` | Base URL for MDA API | Dev |
@@ -208,6 +208,7 @@ blob stays in PG.
| `OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION` | `True` | Use email as fallback identifier | Optional |
| `OIDC_ALLOW_DUPLICATE_EMAILS` | `False` | Allow duplicate emails (โ ๏ธ Security risk) | Optional |
| `OIDC_AUTH_REQUEST_EXTRA_PARAMS` | `{"acr_values": "eidas1"}` | Extra parameters for auth requests | Optional |
+| `OIDC_AUTH_REQUEST_FORWARDED_PARAMS` | `["login_hint"]` | Forwarded parameters for auth requests | Optional |
### User Mapping (โ ๏ธ DEPRECATED)
_Those settings are deprecated and will be removed in the future._
@@ -247,6 +248,20 @@ _Those settings are deprecated and will be removed in the future._
| `NEXT_PUBLIC_SENTRY_DSN` | None | Sentry DSN for error tracking | Optional |
| `NEXT_PUBLIC_SENTRY_ENVIRONMENT` | None | Sentry environment for error tracking | Optional ('production', 'development', 'staging') |
+### Selfcheck
+
+End-to-end mail delivery probe โ see [selfcheck.md](selfcheck.md) for details.
+
+| Variable | Default | Description | Required |
+|----------|---------|-------------|----------|
+| `MESSAGES_SELFCHECK_FROM` | None | Email address the selfcheck sends from. Leave unset to disable the selfcheck. | Optional |
+| `MESSAGES_SELFCHECK_TO` | None | Email address the selfcheck sends to. Leave unset to disable the selfcheck. | Optional |
+| `MESSAGES_SELFCHECK_SECRET` | `self-check-secret-for-dev` | Secret string embedded in the test message body | Optional |
+| `MESSAGES_SELFCHECK_INTERVAL` | `600` | Interval between selfcheck runs, in seconds | Optional |
+| `MESSAGES_SELFCHECK_TIMEOUT` | `60` | Timeout for message reception, in seconds | Optional |
+| `MESSAGES_SELFCHECK_WEBHOOK_URL` | None | Webhook URL POSTed on each successful selfcheck (updown.io-compatible heartbeat) | Optional |
+| `MESSAGES_SELFCHECK_SENTRY_MONITOR_SLUG` | None | Sentry cron monitor slug. When set (with `SENTRY_DSN`), each run is reported as a Sentry check-in. | Optional |
+
### Logging
| Variable | Default | Description | Required |
diff --git a/docs/model-custom-attributes.md b/docs/model-custom-attributes.md
index 531a6666..0e0e6b76 100644
--- a/docs/model-custom-attributes.md
+++ b/docs/model-custom-attributes.md
@@ -54,12 +54,12 @@ Example of a valid JSON Schema:
"minLength": 3,
"x-i18n": {
"title": {
- "fr": "Fonction",
- "en": "Job title"
+ "fr-Fr": "Fonction",
+ "en-US": "Job title"
},
"description": {
- "fr": "Le nom de la fonction de l'utilisateur",
- "en": "The job name of the user"
+ "fr-Fr": "Le nom de la fonction de l'utilisateur",
+ "en-US": "The job name of the user"
}
}
},
@@ -70,11 +70,11 @@ Example of a valid JSON Schema:
"description": "Whether the user is elected",
"x-i18n": {
"title": {
- "fr": "Est รฉlu",
- "en": "Is elected"
+ "fr-Fr": "Est รฉlu",
+ "en-US": "Is elected"
},
"description": {
- "fr": "Indique si l'utilisateur est รฉlu"
+ "fr-Fr": "Indique si l'utilisateur est รฉlu",
}
}
}
diff --git a/docs/selfcheck.md b/docs/selfcheck.md
index f0968033..7f635e61 100644
--- a/docs/selfcheck.md
+++ b/docs/selfcheck.md
@@ -28,6 +28,10 @@ Optionally, to enable uptime alerting via a selfcheck webhook:
- `MESSAGES_SELFCHECK_WEBHOOK_URL`: URL of the selfcheck webhook endpoint (default: `None` - disabled)
+Optionally, to report selfcheck runs to [Sentry Crons](https://docs.sentry.io/product/crons/):
+
+- `MESSAGES_SELFCHECK_SENTRY_MONITOR_SLUG`: Slug of the Sentry cron monitor (default: `None` - disabled). Requires `SENTRY_DSN` to also be set.
+
## Usage
### Manual Execution
@@ -95,6 +99,16 @@ The POST body includes timing data:
{"send_time": 0.15, "reception_time": 2.34}
```
+### Sentry Crons
+
+When `MESSAGES_SELFCHECK_SENTRY_MONITOR_SLUG` is configured (and `SENTRY_DSN` is set), each selfcheck run is reported to [Sentry Crons](https://docs.sentry.io/product/crons/):
+
+- An `in_progress` check-in is opened before the test message is sent.
+- The check-in is closed with status `ok` on success or `error` on failure.
+- On success, the reported `duration` is `send_time + reception_time`, excluding the post-run cleanup pause.
+
+Configure the monitor schedule (interval and grace period) in the Sentry UI to match `MESSAGES_SELFCHECK_INTERVAL`. Runs skipped because `MESSAGES_SELFCHECK_FROM` or `MESSAGES_SELFCHECK_TO` is empty do not produce a check-in.
+
## Security Considerations
- The selfcheck uses dedicated test mailboxes that are separate from user data
diff --git a/env.d/development/frontend.defaults b/env.d/development/frontend.defaults
index ffd235f9..64ab68d2 100644
--- a/env.d/development/frontend.defaults
+++ b/env.d/development/frontend.defaults
@@ -1,5 +1,4 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8901
-NEXT_TELEMETRY_DISABLED=1
NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL=
NEXT_PUBLIC_FEEDBACK_WIDGET_PATH=
NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL=
diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile
index ac6a46e3..56b11929 100644
--- a/src/backend/Dockerfile
+++ b/src/backend/Dockerfile
@@ -18,9 +18,8 @@ WORKDIR /app
FROM base AS uv
# Pin uv by SHA256 digest for supply chain security.
-# Verify with: gh attestation verify --owner astral-sh oci://ghcr.io/astral-sh/uv:0.11.10
-# SLSA provenance: https://github.com/astral-sh/uv/attestations/26524427
-COPY --from=ghcr.io/astral-sh/uv@sha256:bca7f6959666f3524e0c42129f9d8bbcfb0c180d847f5187846b98ff06125ead /uv /uvx /bin/
+# Verify with: gh attestation verify --owner astral-sh oci://ghcr.io/astral-sh/uv:0.11.19
+COPY --from=ghcr.io/astral-sh/uv@sha256:b46b03ddfcfbf8f547af7e9eaefdf8a39c8cebcba7c98858d3162bd28cf536f6 /uv /uvx /bin/
RUN <John Doe
",
+ "text_body": "John Doe"
+ }
+ }
+ }
+ },
+ "description": ""
+ }
+ }
+ }
+ },
"/api/v1.0/mailboxes/{mailbox_id}/message-templates/available/": {
"get": {
"operationId": "mailboxes_message_templates_available_list",
@@ -3344,6 +3786,54 @@
"description": ""
}
}
+ },
+ "patch": {
+ "operationId": "mailboxes_partial_update",
+ "description": "Rename a mailbox (its display contact name). Mailbox admins only.\n\n``partial=True`` keeps true PATCH semantics: omitting ``name`` is a no-op\nrather than a 400, so the runtime matches the optional request schema.",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "schema": {
+ "type": "string"
+ },
+ "required": true
+ }
+ ],
+ "tags": [
+ "mailboxes"
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PatchedMailboxNameUpdateRequest"
+ }
+ },
+ "multipart/form-data": {
+ "schema": {
+ "$ref": "#/components/schemas/PatchedMailboxNameUpdateRequest"
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Mailbox"
+ }
+ }
+ },
+ "description": ""
+ }
+ }
}
},
"/api/v1.0/mailboxes/{id}/search/": {
@@ -4687,15 +5177,33 @@
"application/json": {
"schema": {
"type": "object",
- "description": "Field slugs mapped to their verbose labels",
+ "description": "Field slugs mapped to their label metadata. Built-in fields have an empty object and are localized client-side. Custom attribute fields expose their schema title and optional per-language translations.",
"additionalProperties": {
- "type": "string",
- "description": "Verbose label for the field"
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "description": "Default label (custom fields only)."
+ },
+ "i18n": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Label translations by language code, from the schema 'x-i18n' entry (custom fields only)."
+ }
+ }
},
"example": {
- "name": "Name",
- "job_title": "Job title",
- "is_elected": "Is elected"
+ "name": {},
+ "recipient_name": {},
+ "job_title": {
+ "title": "Job title",
+ "i18n": {
+ "en": "Job title",
+ "fr": "Fonction"
+ }
+ }
}
}
}
@@ -4751,14 +5259,11 @@
"$ref": "#/components/schemas/SendMessageResponse"
},
"examples": {
- "SendDraft": {
+ "SendDraftResult": {
"value": {
- "messageId": "123e4567-e89b-12d3-a456-426614174000",
- "senderId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
- "textBody": "Hello, world!",
- "htmlBody": "Hello, world!
"
+ "task_id": "123e4567-e89b-12d3-a456-426614174000"
},
- "summary": "Send Draft"
+ "summary": "Send Draft Result"
}
}
}
@@ -4789,7 +5294,7 @@
},
"description": ""
},
- "503": {
+ "500": {
"content": {
"application/json": {
"schema": {
@@ -6039,6 +6544,80 @@
}
}
},
+ "/api/v1.0/threads/bulk-delete/": {
+ "post": {
+ "operationId": "threads_bulk_delete_create",
+ "description": "Permanently delete (hard-delete) draft messages within the given accessible and editable threads. A thread emptied by the deletion is removed; otherwise its stats are recomputed.",
+ "tags": [
+ "threads"
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ThreadBulkDeleteRequestRequest"
+ }
+ },
+ "multipart/form-data": {
+ "schema": {
+ "$ref": "#/components/schemas/ThreadBulkDeleteRequestRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean"
+ },
+ "deleted_count": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "success",
+ "deleted_count"
+ ]
+ }
+ }
+ },
+ "description": "Messages permanently deleted."
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "detail": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "description": "Missing or invalid parameters."
+ },
+ "401": {
+ "description": "Authentication credentials were not provided or are invalid."
+ },
+ "403": {
+ "description": "You do not have permission to delete drafts in one or more targeted threads."
+ }
+ }
+ }
+ },
"/api/v1.0/threads/stats/": {
"get": {
"operationId": "threads_stats_retrieve",
@@ -6639,6 +7218,148 @@
"type"
]
},
+ "CalendarAddEventRequestRequest": {
+ "type": "object",
+ "properties": {
+ "ics_data": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Raw ICS content of the event"
+ },
+ "calendar_id": {
+ "type": "string",
+ "nullable": true,
+ "minLength": 1,
+ "description": "Optional specific calendar URL"
+ }
+ },
+ "required": [
+ "ics_data"
+ ]
+ },
+ "CalendarAddEventResponse": {
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "task_id"
+ ]
+ },
+ "CalendarConflictsRequestRequest": {
+ "type": "object",
+ "properties": {
+ "start": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Start of the time range (ISO 8601)"
+ },
+ "end": {
+ "type": "string",
+ "format": "date-time",
+ "description": "End of the time range (ISO 8601)"
+ },
+ "exclude_uid": {
+ "type": "string",
+ "nullable": true,
+ "description": "Optional UID of an event to exclude from conflicts (avoids flagging prior imports of the same invite)."
+ }
+ },
+ "required": [
+ "end",
+ "start"
+ ]
+ },
+ "CalendarConflictsResponse": {
+ "type": "object",
+ "properties": {
+ "conflicts": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": {}
+ }
+ },
+ "existing_partstats": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "PARTSTAT per attendee identity (calendar owner email, lowercased) on the prior copy of ``exclude_uid``. Lets the UI pre-select the right prior RSVP for the *selected* calendar when a mailbox can act through several attendee-owned calendars."
+ }
+ },
+ "required": [
+ "conflicts",
+ "existing_partstats"
+ ]
+ },
+ "CalendarListResponse": {
+ "type": "object",
+ "properties": {
+ "calendars": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": {}
+ }
+ },
+ "web_url": {
+ "type": "string",
+ "nullable": true,
+ "description": "Public URL of the calendar web UI, if configured."
+ },
+ "configured": {
+ "type": "boolean",
+ "description": "True when a CalDAV service is configured for this mailbox (per-mailbox channel or deployment default). False means the integration is disabled."
+ }
+ },
+ "required": [
+ "calendars",
+ "configured",
+ "web_url"
+ ]
+ },
+ "CalendarRsvpRequestRequest": {
+ "type": "object",
+ "properties": {
+ "ics_data": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Raw ICS content of the event"
+ },
+ "response": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/ResponseEnum"
+ }
+ ],
+ "description": "RSVP response\n\n* `ACCEPTED` - ACCEPTED\n* `DECLINED` - DECLINED\n* `TENTATIVE` - TENTATIVE"
+ },
+ "calendar_id": {
+ "type": "string",
+ "nullable": true,
+ "minLength": 1,
+ "description": "Optional specific calendar URL"
+ }
+ },
+ "required": [
+ "ics_data",
+ "response"
+ ]
+ },
+ "CalendarRsvpResponse": {
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "task_id"
+ ]
+ },
"ChangeFlagRequestRequest": {
"type": "object",
"properties": {
@@ -7207,7 +7928,27 @@
"description": "date and time at which a record was last updated"
},
"expected_dns_records": {
- "type": "string",
+ "type": "array",
+ "nullable": true,
+ "items": {
+ "type": "object",
+ "properties": {
+ "target": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "target",
+ "type",
+ "value"
+ ]
+ },
"readOnly": true
},
"mailbox_count": {
@@ -7363,6 +8104,17 @@
"type": "string",
"readOnly": true
},
+ "name": {
+ "type": "string",
+ "nullable": true,
+ "description": "Return the display name of the mailbox (its contact name).",
+ "readOnly": true
+ },
+ "domain_id": {
+ "type": "string",
+ "format": "uuid",
+ "readOnly": true
+ },
"is_identity": {
"type": "boolean",
"readOnly": true,
@@ -7379,6 +8131,7 @@
"$ref": "#/components/schemas/MailboxRoleChoices"
}
],
+ "nullable": true,
"readOnly": true
},
"count_unread_threads": {
@@ -7478,10 +8231,12 @@
"count_threads",
"count_unread_mentions",
"count_unread_threads",
+ "domain_id",
"email",
"id",
"is_identity",
"is_shared",
+ "name",
"role"
]
},
@@ -7687,7 +8442,8 @@
"$ref": "#/components/schemas/Contact"
}
],
- "readOnly": true
+ "readOnly": true,
+ "nullable": true
},
"last_accessed_at": {
"type": "string",
@@ -7777,7 +8533,8 @@
"$ref": "#/components/schemas/Contact"
}
],
- "readOnly": true
+ "readOnly": true,
+ "nullable": true
},
"last_accessed_at": {
"type": "string",
@@ -8750,6 +9507,17 @@
}
}
},
+ "PatchedMailboxNameUpdateRequest": {
+ "type": "object",
+ "description": "Validate and apply a mailbox display-name update (its contact name).",
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255
+ }
+ }
+ },
"PatchedMessageTemplateRequest": {
"type": "object",
"description": "Serialize message templates for POST/PUT/PATCH operations.",
@@ -8986,6 +9754,22 @@
"one_time_password"
]
},
+ "ResponseEnum": {
+ "enum": [
+ "ACCEPTED",
+ "DECLINED",
+ "TENTATIVE"
+ ],
+ "type": "string",
+ "description": "* `ACCEPTED` - ACCEPTED\n* `DECLINED` - DECLINED\n* `TENTATIVE` - TENTATIVE"
+ },
+ "ScopeEnum": {
+ "enum": [
+ "draft"
+ ],
+ "type": "string",
+ "description": "* `draft` - draft"
+ },
"ScopeLevelEnum": {
"enum": [
"global",
@@ -9027,16 +9811,13 @@
"SendMessageResponse": {
"type": "object",
"properties": {
- "message": {
- "$ref": "#/components/schemas/Message"
- },
"task_id": {
"type": "string",
+ "format": "uuid",
"description": "Task ID for tracking"
}
},
"required": [
- "message",
"task_id"
]
},
@@ -9217,6 +9998,7 @@
"$ref": "#/components/schemas/ThreadAccessRoleChoices"
}
],
+ "nullable": true,
"readOnly": true
},
"accesses": {
@@ -9420,6 +10202,39 @@
"editor"
]
},
+ "ThreadBulkDeleteRequestRequest": {
+ "type": "object",
+ "description": "Payload for the bulk-delete endpoint: a scope and the threads/messages\nto permanently delete.",
+ "properties": {
+ "scope": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/ScopeEnum"
+ }
+ ],
+ "description": "Which messages to permanently delete. Only 'draft' (draft messages) is supported.\n\n* `draft` - draft"
+ },
+ "thread_ids": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "description": "Threads whose scope-matching messages should be deleted."
+ },
+ "message_ids": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "description": "Specific messages to delete (still scope-filtered)."
+ }
+ },
+ "required": [
+ "scope"
+ ]
+ },
"ThreadEvent": {
"type": "object",
"description": "Serialize thread event information.",
diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py
index df47e4cb..906e1ecc 100644
--- a/src/backend/core/api/permissions.py
+++ b/src/backend/core/api/permissions.py
@@ -205,78 +205,6 @@ class IsAllowedToAccess(IsAuthenticated):
return False
-class IsAllowedToCreateMessage(IsAuthenticated):
- """Permission class for access to create a message."""
-
- def has_permission(self, request, view):
- """Check if user is allowed to create a message."""
-
- if not IsAuthenticated.has_permission(self, request, view):
- return False
-
- # a sender mailbox is required to create/send a message
- sender_id = request.data.get("senderId")
- parent_id = request.data.get("parentId")
- if not sender_id:
- return False
-
- # get mailbox instance from sender id
- try:
- # Store mailbox on the view for later use (e.g., in the view logic)
- view.mailbox = models.Mailbox.objects.get(id=sender_id)
- except models.Mailbox.DoesNotExist:
- return False # Invalid senderId
-
- # Check if user has required role on the sender Mailbox
- has_edit_role = view.mailbox.accesses.filter(
- user=request.user,
- role__in=enums.MAILBOX_ROLES_CAN_EDIT,
- ).exists()
-
- # if user does not have edit role with this sender mailbox, return False
- if not has_edit_role:
- return False
-
- # --- Additional check for replies ---
- # If creating a reply (parentId is provided), check access to the parent thread
- if parent_id:
- try:
- parent_message = models.Message.objects.select_related("thread").get(
- id=parent_id
- )
- # Check if the user has access to the thread they are replying to
- if models.ThreadAccess.objects.filter(
- thread=parent_message.thread,
- mailbox=view.mailbox,
- role=enums.ThreadAccessRoleChoices.EDITOR,
- ).exists():
- return True
- except models.Message.DoesNotExist:
- return False # Treat invalid parentId as permission failure
-
- # --- Additional check for updating existing draft ---
- # If updating (messageId is provided), check access to the draft's thread
- message_id = request.data.get("messageId")
- if message_id and request.method == "PUT": # Check only needed for updates
- try:
- draft_message = models.Message.objects.select_related("thread").get(
- id=message_id, is_draft=True
- )
- # Check if the user has access to the thread of the draft being updated
- if not models.ThreadAccess.objects.filter(
- thread=draft_message.thread,
- mailbox=view.mailbox,
- role=enums.ThreadAccessRoleChoices.EDITOR,
- ).exists():
- return False
- except models.Message.DoesNotExist:
- # Let the view handle invalid messageId
- return False # Treat invalid messageId as permission failure
-
- # If all checks pass
- return True
-
-
def _user_can_manage_thread_access(user, thread_id):
"""True if ``user`` has full edit rights on the thread.
@@ -447,6 +375,40 @@ class IsMailboxAdmin(permissions.BasePermission):
return is_domain_admin
+class IsMailboxAdminObject(permissions.BasePermission):
+ """Object-level permission granting access on a Mailbox instance to its
+ admins (MailboxAccess role ADMIN), its domain admins, or superusers.
+
+ Unlike :class:`IsMailboxAdmin`, this works on the resolved Mailbox object
+ (e.g. ``/mailboxes/{pk}/``) rather than a nested ``mailbox_id`` URL kwarg.
+ """
+
+ message = "You do not have administrative rights for this mailbox or its domain."
+
+ def has_object_permission(self, request, view, obj):
+ user = request.user
+ if not user or not user.is_authenticated:
+ return False
+
+ if user.is_superuser:
+ return True
+
+ is_mailbox_admin = models.MailboxAccess.objects.filter(
+ user=user, mailbox=obj, role=models.MailboxRoleChoices.ADMIN
+ ).exists()
+ if is_mailbox_admin:
+ return True
+
+ if obj.domain:
+ return models.MailDomainAccess.objects.filter(
+ user=user,
+ maildomain=obj.domain,
+ role=models.MailDomainAccessRoleChoices.ADMIN,
+ ).exists()
+
+ return False
+
+
class HasChannelScope(permissions.BasePermission):
"""Scope-based permission for service calls authenticated as a Channel.
@@ -691,3 +653,22 @@ class HasAccessToMailbox(IsAuthenticated):
return models.MailboxAccess.objects.filter(
user=request.user, mailbox=view.kwargs.get("mailbox_id")
).exists()
+
+
+class HasWriteAccessToMailbox(IsAuthenticated):
+ """Allows access only to users with an editor-or-above role on the mailbox.
+
+ Use for state-changing endpoints whose effect is observable beyond the
+ mailbox itself (e.g. writing to the mailbox's CalDAV calendar, which a
+ VIEWER access shouldn't be able to do).
+ """
+
+ def has_permission(self, request, view):
+ if not super().has_permission(request, view):
+ return False
+
+ return models.MailboxAccess.objects.filter(
+ user=request.user,
+ mailbox=view.kwargs.get("mailbox_id"),
+ role__in=enums.MAILBOX_ROLES_CAN_EDIT,
+ ).exists()
diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py
index 2a33e0c9..a9f5754d 100644
--- a/src/backend/core/api/serializers.py
+++ b/src/backend/core/api/serializers.py
@@ -15,7 +15,7 @@ from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from core import enums, models
-from core.mda.rfc5322 import extract_base64_images_from_html
+from core.mda.inline_images import extract_inline_images_html
from core.services.blob_gc import schedule_for_gc
from core.services.identity import keycloak as keycloak_service
@@ -220,6 +220,23 @@ class IntegerChoicesField(serializers.ChoiceField):
super().fail(key, **kwargs)
+def nullable_choices_schema(choices_class):
+ """Nullable enum schema for a ``SerializerMethodField`` that may return
+ ``None``.
+
+ Reuse the shared ``{choices_class.__name__}`` component (registered by the
+ non-null :class:`IntegerChoicesField` usages) and apply the nullability
+ locally via the ``allOf`` wrapper. Baking ``nullable`` into the component
+ itself would collide with those non-null usages; an inline enum dict would
+ instead make drf-spectacular extract a redundant ``โฆEnum`` component. This
+ mirrors the shape drf-spectacular emits natively for a nullable ``$ref``.
+ """
+ return {
+ "allOf": [{"$ref": f"#/components/schemas/{choices_class.__name__}"}],
+ "nullable": True,
+ }
+
+
class AbilitiesModelSerializer(serializers.ModelSerializer):
"""
A ModelSerializer that takes an additional `exclude` argument that
@@ -342,6 +359,8 @@ class MailboxSerializer(AbilitiesModelSerializer):
"""Serialize mailboxes."""
email = serializers.SerializerMethodField(read_only=True)
+ name = serializers.SerializerMethodField(read_only=True)
+ domain_id = serializers.UUIDField(read_only=True)
role = serializers.SerializerMethodField(read_only=True)
count_unread_threads = serializers.SerializerMethodField(read_only=True)
count_threads = serializers.SerializerMethodField(read_only=True)
@@ -355,6 +374,8 @@ class MailboxSerializer(AbilitiesModelSerializer):
fields = [
"id",
"email",
+ "name",
+ "domain_id",
"is_identity",
"is_shared",
"role",
@@ -370,7 +391,13 @@ class MailboxSerializer(AbilitiesModelSerializer):
"""Return the email of the mailbox."""
return str(instance)
- @extend_schema_field(IntegerChoicesField(choices_class=models.MailboxRoleChoices))
+ def get_name(self, instance) -> str | None:
+ """Return the display name of the mailbox (its contact name)."""
+ if instance.contact:
+ return instance.contact.name
+ return None
+
+ @extend_schema_field(nullable_choices_schema(models.MailboxRoleChoices))
def get_role(self, instance):
"""Return the allowed actions of the logged-in user on the instance."""
# Use the annotated user_role field
@@ -497,6 +524,35 @@ class MailboxSerializer(AbilitiesModelSerializer):
return super().get_abilities(instance)
+class MailboxNameUpdateSerializer(serializers.Serializer):
+ """Validate and apply a mailbox display-name update (its contact name)."""
+
+ name = serializers.CharField(max_length=255)
+
+ def validate_name(self, value):
+ """Strip surrounding whitespace and reject whitespace-only names, which
+ would otherwise be stored (and sent in the ``From`` header) verbatim."""
+ value = value.strip()
+ if not value:
+ raise serializers.ValidationError("Name cannot be blank.")
+ return value
+
+ def create(self, validated_data):
+ """Do not allow creating instances from this serializer."""
+ raise RuntimeError(f"{self.__class__.__name__} does not support create method")
+
+ def update(self, instance, validated_data):
+ """Persist the new display name through the mailbox helper.
+
+ The PATCH is partial: an absent ``name`` is a no-op, so callers may send
+ an empty body without error. This matches the optional ``name`` in the
+ generated OpenAPI request schema.
+ """
+ if "name" in validated_data:
+ instance.set_display_name(validated_data["name"])
+ return instance
+
+
class MailboxLightSerializer(serializers.ModelSerializer):
"""Serializer for mailbox details in thread access."""
@@ -851,9 +907,7 @@ class ThreadSerializer(serializers.ModelSerializer):
cached = instance.messages.order_by("created_at")
return [str(message.id) for message in cached]
- @extend_schema_field(
- IntegerChoicesField(choices_class=models.ThreadAccessRoleChoices)
- )
+ @extend_schema_field(nullable_choices_schema(models.ThreadAccessRoleChoices))
def get_user_role(self, instance):
"""Get current user's role for this thread, scoped to the context mailbox.
@@ -1116,10 +1170,11 @@ class MessageSerializer(serializers.ModelSerializer):
stripped_attachments.append(
{
"blobId": f"msg_{instance.id}_{index}",
- "name": attachment["name"],
+ "name": attachment.get("name") or "unnamed",
"size": attachment["size"],
"type": attachment["type"],
"cid": attachment.get("cid"),
+ "sha256": attachment.get("sha256"),
}
)
return stripped_attachments
@@ -1404,6 +1459,21 @@ class MailDomainAdminSerializer(AbilitiesModelSerializer):
"""Return the abilities for the mail domain."""
return super().get_abilities(instance)
+ @extend_schema_field(
+ {
+ "type": "array",
+ "nullable": True,
+ "items": {
+ "type": "object",
+ "properties": {
+ "target": {"type": "string"},
+ "type": {"type": "string"},
+ "value": {"type": "string"},
+ },
+ "required": ["target", "type", "value"],
+ },
+ }
+ )
def get_expected_dns_records(self, instance):
"""Return the expected DNS records for the mail domain, only in detail views."""
@@ -1512,7 +1582,9 @@ class MailboxAdminSerializer(serializers.ModelSerializer):
many=True, read_only=True
) # accesses is the related_name
can_reset_password = serializers.BooleanField(read_only=True)
- contact = ContactSerializer(read_only=True)
+ # ``Mailbox.contact`` is ``SET_NULL, null=True`` โ an alias mailbox (or one
+ # whose contact was deleted) has none, so the nested field must be nullable.
+ contact = ContactSerializer(read_only=True, allow_null=True)
alias_of = serializers.PrimaryKeyRelatedField(
required=False, allow_null=True, queryset=models.Mailbox.objects.none()
)
@@ -1606,17 +1678,13 @@ class MailboxAdminSerializer(serializers.ModelSerializer):
"Domain is required in serializer context."
)
- domain = self.context.get("domain")
metadata = self.context.get("metadata", {})
- if metadata.get("type") == "personal" and not domain.identity_sync:
- raise serializers.ValidationError(
- {
- "identity_sync": (
- "Personal mailboxes cannot be created when "
- "identity synchronization is disabled."
- )
- }
- )
+
+ # Personal mailboxes can be created even when identity synchronization is
+ # disabled: this lets admins pre-create mailboxes for users who connect
+ # through a third-party (non-synced) identity provider. No password is
+ # provisioned in that case (see Mailbox.can_reset_password), but the
+ # mailbox can receive emails straight away.
if metadata.get("type") == "personal":
local_part = attrs.get("local_part", "")
@@ -1713,11 +1781,10 @@ class MailboxAdminSerializer(serializers.ModelSerializer):
if instance.is_identity is True:
user_updated_fields = {}
- contact_updated_fields = {}
+ display_name = metadata.get("full_name")
- if full_name := metadata.get("full_name"):
- user_updated_fields["full_name"] = full_name
- contact_updated_fields["name"] = full_name
+ if display_name:
+ user_updated_fields["full_name"] = display_name
if custom_attributes := metadata.get("custom_attributes"):
user_updated_fields["custom_attributes"] = custom_attributes
@@ -1732,21 +1799,12 @@ class MailboxAdminSerializer(serializers.ModelSerializer):
owner.save(update_fields=list(user_updated_fields.keys()))
updated = True
- if contact_updated_fields:
- contact = models.Contact.objects.filter(pk=instance.contact_id)
- contact.update(**contact_updated_fields)
- updated = True
-
else:
- contact_updated_fields = {}
+ display_name = metadata.get("name")
- if name := metadata.get("name"):
- contact_updated_fields["name"] = name
-
- if contact_updated_fields:
- contact = models.Contact.objects.filter(pk=instance.contact_id)
- contact.update(**contact_updated_fields)
- updated = True
+ if display_name:
+ instance.set_display_name(display_name)
+ updated = True
if updated:
instance.refresh_from_db()
@@ -1965,6 +2023,10 @@ class ChannelSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
# generators write directly to ``encrypted_settings`` instead.
RESERVED_SETTINGS_KEYS = {
enums.ChannelTypes.API_KEY: ["api_key_hashes"],
+ # CalDAV credentials must live in ``encrypted_settings``, never in
+ # the plaintext ``settings`` JSONField โ a DB read would otherwise
+ # surface every user's CalDAV password.
+ enums.ChannelTypes.CALDAV: ["username", "password"],
}
def create(self, validated_data):
@@ -2334,7 +2396,7 @@ class MessageTemplateSerializer(serializers.ModelSerializer):
attrs.pop("text_body")
attrs.pop("raw_body")
else:
- _html, images = extract_base64_images_from_html(attrs["html_body"])
+ _html, images = extract_inline_images_html(attrs["html_body"])
total_image_size = 0
for image in images:
total_image_size += image["size"]
@@ -2553,3 +2615,51 @@ class ProvisioningMailDomainSerializer(serializers.Serializer):
def update(self, instance, validated_data):
"""This serializer is only used to validate the data, not to create or update."""
+
+
+class ThreadBulkDeleteRequestSerializer(serializers.Serializer):
+ """Payload for the bulk-delete endpoint: a scope and the threads/messages
+ to permanently delete."""
+
+ # Scopes accepted by the bulk-delete endpoint, mapping each to the queryset
+ # filter selecting the messages whose rows get permanently removed. Only
+ # "draft" is exposed for now: trashed deletion is intentionally not offered
+ # until the product behavior for trashed messages is decided.
+ BULK_DELETE_SCOPE_FILTERS = {"draft": {"is_draft": True}}
+ BULK_DELETE_SCOPES = list(BULK_DELETE_SCOPE_FILTERS)
+
+ scope = serializers.ChoiceField(
+ choices=BULK_DELETE_SCOPES,
+ help_text=(
+ "Which messages to permanently delete. Only 'draft' "
+ "(draft messages) is supported."
+ ),
+ )
+ thread_ids = serializers.ListField(
+ child=serializers.UUIDField(),
+ required=False,
+ allow_empty=True,
+ default=list,
+ help_text="Threads whose scope-matching messages should be deleted.",
+ )
+ message_ids = serializers.ListField(
+ child=serializers.UUIDField(),
+ required=False,
+ allow_empty=True,
+ default=list,
+ help_text="Specific messages to delete (still scope-filtered).",
+ )
+
+ def validate(self, attrs):
+ """Require at least one target list to be non-empty."""
+ if not attrs["thread_ids"] and not attrs["message_ids"]:
+ raise serializers.ValidationError(
+ "Provide at least one of thread_ids or message_ids."
+ )
+ return attrs
+
+ def create(self, validated_data):
+ """This serializer is only used to validate the data, not to create or update."""
+
+ def update(self, instance, validated_data):
+ """This serializer is only used to validate the data, not to create or update."""
diff --git a/src/backend/core/api/viewsets/blob.py b/src/backend/core/api/viewsets/blob.py
index b3fc281b..a169a930 100644
--- a/src/backend/core/api/viewsets/blob.py
+++ b/src/backend/core/api/viewsets/blob.py
@@ -3,15 +3,21 @@
import logging
from django.conf import settings
+from django.core.exceptions import ValidationError as DjangoValidationError
from django.http import HttpResponse
-from django.utils.decorators import method_decorator
from django.utils.http import content_disposition_header
-from django.views.decorators.csrf import csrf_exempt
+import magic
+from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework import status
from rest_framework.decorators import action
-from rest_framework.exceptions import NotFound
+from rest_framework.exceptions import (
+ APIException,
+ NotFound,
+ ParseError,
+ PermissionDenied,
+)
from rest_framework.parsers import MultiPartParser
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
@@ -20,6 +26,12 @@ from core import enums, models
from core.api import permissions, utils
from core.services.blob_gc import upload_and_reserve_blob
+# Number of leading bytes inspected by python-magic on the preview endpoint.
+# Every format we allowlist is identified by a signature in its first few hundred
+# bytes, so 2 KiB is a comfortable margin โ not a tight bound. We cap the slice
+# only to avoid handing magic the whole payload before deciding to refuse.
+_PREVIEW_MAGIC_SNIFF_BYTES = 2048
+
# Define logger
logger = logging.getLogger(__name__)
@@ -90,7 +102,6 @@ class BlobViewSet(ViewSet):
},
tags=["blob"],
)
- @method_decorator(csrf_exempt)
@action(detail=False, methods=["post"], url_path="upload/(?P[^/.]+)")
def upload(self, request, mailbox_id=None):
"""
@@ -167,6 +178,58 @@ class BlobViewSet(ViewSet):
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
+ def _resolve_blob_source(self, pk, user):
+ """Resolve a blob to its bytes and metadata.
+
+ `msg_*` IDs are served from the parsed message attachment cache
+
+ Returns:
+ A dict with keys `content` (bytes), `declared_type` (str),
+ `filename` (str), `size` (int).
+
+ Raises:
+ ParseError: malformed `msg_*` ID.
+ NotFound: `msg_*` ID points at a missing attachment.
+ PermissionDenied: blob doesn't exist or user has no access
+ """
+ if pk.startswith("msg_"):
+ try:
+ attachment = utils.get_attachment_from_blob_id(pk, user)
+ except ValueError as e:
+ raise ParseError("Invalid blob ID") from e
+ except models.Blob.DoesNotExist as e:
+ raise NotFound("Blob not found") from e
+ return {
+ "content": attachment["content"],
+ "declared_type": attachment["type"],
+ "filename": attachment["name"],
+ "size": attachment["size"],
+ }
+
+ try:
+ blob = models.Blob.objects.get(id=pk)
+ except DjangoValidationError as e:
+ # Non-UUID ``pk`` reaches the ORM as a ValidationError; surface
+ # it as a 400 instead of falling through to the generic 500.
+ raise ParseError("Invalid blob ID") from e
+ except models.Blob.DoesNotExist as e:
+ raise PermissionDenied(
+ "You do not have permission to access this blob"
+ ) from e
+
+ if not models.Blob.objects.user_can_access(user, blob.id):
+ raise PermissionDenied("You do not have permission to access this blob")
+ attachment_row = models.Attachment.objects.filter(blob=blob).first()
+
+ return {
+ "content": blob.get_content(),
+ "declared_type": blob.content_type,
+ "filename": (
+ attachment_row.name if attachment_row else f"blob-{blob.id}.bin"
+ ),
+ "size": blob.size,
+ }
+
@action(detail=True, methods=["get"])
def download(self, request, pk=None):
"""
@@ -176,71 +239,23 @@ class BlobViewSet(ViewSet):
by checking if the user has access to any mailbox that owns this blob.
"""
try:
- # Blob IDs in the form msg_[message_id]_[attachment_number] are looked up
- # directly in the message's attachments.
- if pk.startswith("msg_"):
- try:
- attachment = utils.get_attachment_from_blob_id(pk, request.user)
- except ValueError as e:
- return Response(
- status=status.HTTP_400_BAD_REQUEST, data={"error": str(e)}
- )
- except models.Blob.DoesNotExist as e:
- return Response(
- status=status.HTTP_404_NOT_FOUND, data={"error": str(e)}
- )
-
- # Create response with decompressed content
- response = HttpResponse(
- attachment["content"], content_type=attachment["type"]
- )
-
- # Add appropriate headers for download
- response["Content-Disposition"] = content_disposition_header(
- True, attachment["name"]
- )
- response["Content-Length"] = attachment["size"]
- # Enable browser caching for 30 days (inline images benefit from this)
- response["Cache-Control"] = "private, max-age=2592000"
-
- else:
- # Get the blob
- blob = models.Blob.objects.get(id=pk)
-
- # Authz: walk the reference graph (Attachment, Message,
- # MessageTemplate) plus any active upload reservation.
- # See ``BlobManager.user_can_access`` for the union.
- if not models.Blob.objects.user_can_access(request.user, blob.id):
- return Response(
- {"error": "You do not have permission to download this blob"},
- status=status.HTTP_403_FORBIDDEN,
- )
-
- # Get the first attachment name to use as filename (if available)
- attachment = models.Attachment.objects.filter(blob=blob).first()
- filename = attachment.name if attachment else f"blob-{blob.id}.bin"
-
- # Create response with decompressed content
- response = HttpResponse(
- blob.get_content(), content_type=blob.content_type
- )
-
- # Add appropriate headers for download
- response["Content-Disposition"] = content_disposition_header(
- True, filename
- )
- response["Content-Length"] = blob.size
- # Enable browser caching for 30 days (inline images benefit from this)
- response["Cache-Control"] = "private, max-age=2592000"
+ source = self._resolve_blob_source(pk, request.user)
+ response = HttpResponse(
+ source["content"], content_type=source["declared_type"]
+ )
+ response["Content-Disposition"] = content_disposition_header(
+ True, source["filename"]
+ )
+ response["Content-Length"] = source["size"]
+ # Enable browser caching for 30 days (inline images benefit from this)
+ response["Cache-Control"] = "private, max-age=2592000"
return response
- except models.Blob.DoesNotExist:
- # Same error to hide blob existence
- return Response(
- {"error": "You do not have permission to download this blob"},
- status=status.HTTP_403_FORBIDDEN,
- )
+ except APIException:
+ # Let DRF convert ParseError / NotFound / PermissionDenied raised by
+ # ``_resolve_blob_source`` into the proper Response.
+ raise
# pylint: disable=broad-exception-caught
except Exception as e:
logger.exception("Error downloading file: %s", str(e))
@@ -248,3 +263,125 @@ class BlobViewSet(ViewSet):
{"error": "Error downloading file"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
+
+ @extend_schema(
+ responses={
+ (200, "application/octet-stream"): OpenApiResponse(
+ description=(
+ "Inline preview of the blob. The Content-Type is the MIME "
+ "type detected server-side and is guaranteed to belong to "
+ "``PREVIEWABLE_MIME_TYPES``."
+ ),
+ response=OpenApiTypes.BINARY,
+ ),
+ 400: OpenApiResponse(description="Invalid blob ID"),
+ 403: OpenApiResponse(
+ description="Forbidden - User does not have permission to preview this blob"
+ ),
+ 404: OpenApiResponse(description="Blob not found"),
+ 415: OpenApiResponse(
+ description=(
+ "Unsupported media type for inline preview. The detected "
+ "MIME is not in ``PREVIEWABLE_MIME_TYPES`` or does not "
+ "match the declared Content-Type. The response body "
+ "includes a ``code`` field set to either ``suspicious`` "
+ "(declared type was previewable but bytes disagree) or "
+ "``unsupported`` (type is plainly not previewable)."
+ ),
+ ),
+ 500: OpenApiResponse(description="Internal server error"),
+ },
+ tags=["blob"],
+ )
+ @action(detail=True, methods=["get"])
+ def preview(self, request, pk=None):
+ """
+ Serve a blob inline for the FilePreview viewer.
+
+ Sibling of ``download`` with the same authorization model but two
+ extra guarantees:
+
+ - the response Content-Type is the MIME type detected from the bytes
+ (via ``python-magic``), not the value declared at upload time;
+ - the detected MIME must belong to ``PREVIEWABLE_MIME_TYPES``,
+ otherwise the endpoint refuses with 415.
+
+ Returning 415 (rather than 200 with the raw payload) is the security
+ contract that lets the frontend render the response inline: any byte
+ we send back has been re-classified server-side as one of the safe
+ previewable types.
+ """
+ try:
+ source = self._resolve_blob_source(pk, request.user)
+ content = source["content"]
+ declared_type = source["declared_type"]
+
+ # Normalize the declared Content-Type (e.g. image/PNG; charset=binary)
+ declared_media_type = declared_type.partition(";")[0].strip().lower()
+ detected_type = magic.from_buffer(
+ content[:_PREVIEW_MAGIC_SNIFF_BYTES], mime=True
+ ).lower()
+
+ # A preview is served only when the detected bytes are an
+ # allowlisted type AND match the declared Content-Type. Anything
+ # else is refused โ the browser must never render bytes the
+ # uploader lied about. The blob can still be downloaded via
+ # /download/.
+ if (
+ detected_type not in enums.PREVIEWABLE_MIME_TYPES
+ or detected_type != declared_media_type
+ ):
+ # When the *declared* type was itself previewable, the bytes
+ # don't back that claim โ flag the attachment as suspicious.
+ # Otherwise it's plainly not previewable.
+ suspicious = declared_media_type in enums.PREVIEWABLE_MIME_TYPES
+ code = (
+ enums.PreviewRefusalCode.SUSPICIOUS
+ if suspicious
+ else enums.PreviewRefusalCode.UNSUPPORTED
+ )
+ logger.log(
+ logging.WARNING if suspicious else logging.INFO,
+ "Refused preview for blob %s: declared %s, detected %s (%s)",
+ pk,
+ declared_type,
+ detected_type,
+ code.value,
+ )
+ return Response(
+ {
+ "error": "File type not supported for preview",
+ "code": code.value,
+ },
+ status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
+ )
+
+ response = HttpResponse(content, content_type=detected_type)
+ response["Content-Disposition"] = content_disposition_header(
+ False, source["filename"]
+ )
+ response["Content-Length"] = source["size"]
+ response["Cache-Control"] = "private, max-age=2592000"
+ # Defense in depth on top of the global SECURE_CONTENT_TYPE_NOSNIFF.
+ response["X-Content-Type-Options"] = "nosniff"
+ response["Referrer-Policy"] = "no-referrer"
+ # Strict CSP: the response is expected to be loaded only via
+ #
,