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 + # ,