From 41c2d8c62a9faf0d5eaca7592e6ad64d50625ebf Mon Sep 17 00:00:00 2001 From: Sylvinus Date: Wed, 13 May 2026 23:04:43 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(clientbridge)=20add=20new=20client=20?= =?UTF-8?q?bridge=20&=20refactor=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/crowdin_download.yml | 3 + .github/workflows/crowdin_upload.yml | 3 + .github/workflows/docker-publish.yml | 3 + .github/workflows/messages-ghcr.yml | 16 + .github/workflows/messages.yml | 49 +- Makefile | 57 +- README.md | 11 +- compose.yaml | 44 + docs/env.md | 24 + env.d/development/backend.defaults | 5 + env.d/development/backend.e2e | 4 + env.d/development/client-bridge.defaults | 8 + env.d/development/client-bridge.e2e | 2 + src/backend/.pylintrc | 2 +- src/backend/README.md | 1 - src/backend/core/api/authentication.py | 178 +++- src/backend/core/api/openapi.json | 31 + src/backend/core/api/permissions.py | 8 +- src/backend/core/api/serializers.py | 109 ++- src/backend/core/api/viewsets/channel.py | 6 +- .../core/api/viewsets/client_bridge.py | 145 +++ src/backend/core/api/viewsets/config.py | 39 +- src/backend/core/api/viewsets/inbound/mta.py | 56 +- src/backend/core/api/viewsets/metrics.py | 8 +- src/backend/core/api/viewsets/provisioning.py | 6 +- src/backend/core/api/viewsets/send.py | 10 + src/backend/core/api/viewsets/submit.py | 133 ++- src/backend/core/enums.py | 31 +- src/backend/core/mda/outbound.py | 57 +- src/backend/core/services/search/search.py | 2 +- .../tests/api/test_channel_api_key_auth.py | 16 +- .../tests/api/test_channel_scope_level.py | 6 +- .../core/tests/api/test_client_bridge.py | 910 ++++++++++++++++++ src/backend/core/tests/api/test_config.py | 1 + .../tests/api/test_mailbox_usage_metrics.py | 4 +- .../api/test_maildomain_users_metrics.py | 4 +- .../tests/api/test_provisioning_mailbox.py | 8 +- .../api/test_provisioning_maildomains.py | 6 +- src/backend/core/tests/api/test_submit.py | 6 +- src/backend/core/urls.py | 10 + .../management/commands/e2e_clientbridge.py | 186 ++++ .../e2e/management/commands/e2e_demo.py | 143 ++- src/backend/messages/settings.py | 37 + src/backend/pylint_custom.py | 50 + src/client-bridge/Dockerfile | 78 ++ src/client-bridge/README.md | 205 ++++ src/client-bridge/entrypoint.sh | 17 + src/client-bridge/pyproject.toml | 111 +++ src/client-bridge/src/__init__.py | 0 src/client-bridge/src/api/__init__.py | 0 src/client-bridge/src/api/client.py | 217 +++++ src/client-bridge/src/backend.py | 206 ++++ src/client-bridge/src/mailbox.py | 642 ++++++++++++ src/client-bridge/src/server.py | 121 +++ src/client-bridge/src/session.py | 36 + src/client-bridge/src/settings.py | 40 + src/client-bridge/src/submission.py | 141 +++ src/client-bridge/tests/__init__.py | 0 src/client-bridge/tests/conftest.py | 646 +++++++++++++ src/client-bridge/tests/test_imap_auth.py | 67 ++ src/client-bridge/tests/test_imap_folders.py | 103 ++ src/client-bridge/tests/test_imap_messages.py | 234 +++++ .../tests/test_imap_operations.py | 481 +++++++++ .../tests/test_session_expiry.py | 94 ++ .../tests/test_smtp_submission.py | 93 ++ src/client-bridge/uv.lock | 562 +++++++++++ src/e2e/bin/backend-manage.sh | 2 +- src/e2e/compose.yaml | 26 +- src/e2e/package.json | 4 + src/e2e/src/__tests__/client-bridge.spec.ts | 225 +++++ src/e2e/src/__tests__/signatures.spec.ts | 16 +- src/e2e/src/constants.ts | 5 + src/e2e/src/utils.ts | 21 +- src/frontend/public/locales/common/en-US.json | 30 +- src/frontend/public/locales/common/fr-FR.json | 32 +- .../api/gen/models/config_retrieve200.ts | 3 + ...g_retrieve200_clientbridgepublicconfi_g.ts | 19 + .../src/features/api/gen/models/index.ts | 2 + .../gen/models/rotate_password_response.ts | 11 + .../integrations-data-grid.tsx | 4 + .../modal-compose-integration/_index.scss | 53 +- .../client-bridge-integration-form.tsx | 390 ++++++++ .../modal-compose-integration/index.tsx | 19 +- .../src/features/providers/config.tsx | 1 + 84 files changed, 7188 insertions(+), 207 deletions(-) create mode 100644 env.d/development/client-bridge.defaults create mode 100644 env.d/development/client-bridge.e2e delete mode 100644 src/backend/README.md create mode 100644 src/backend/core/api/viewsets/client_bridge.py create mode 100644 src/backend/core/tests/api/test_client_bridge.py create mode 100644 src/backend/e2e/management/commands/e2e_clientbridge.py create mode 100644 src/backend/pylint_custom.py create mode 100644 src/client-bridge/Dockerfile create mode 100644 src/client-bridge/README.md create mode 100644 src/client-bridge/entrypoint.sh create mode 100644 src/client-bridge/pyproject.toml create mode 100644 src/client-bridge/src/__init__.py create mode 100644 src/client-bridge/src/api/__init__.py create mode 100644 src/client-bridge/src/api/client.py create mode 100644 src/client-bridge/src/backend.py create mode 100644 src/client-bridge/src/mailbox.py create mode 100644 src/client-bridge/src/server.py create mode 100644 src/client-bridge/src/session.py create mode 100644 src/client-bridge/src/settings.py create mode 100644 src/client-bridge/src/submission.py create mode 100644 src/client-bridge/tests/__init__.py create mode 100644 src/client-bridge/tests/conftest.py create mode 100644 src/client-bridge/tests/test_imap_auth.py create mode 100644 src/client-bridge/tests/test_imap_folders.py create mode 100644 src/client-bridge/tests/test_imap_messages.py create mode 100644 src/client-bridge/tests/test_imap_operations.py create mode 100644 src/client-bridge/tests/test_session_expiry.py create mode 100644 src/client-bridge/tests/test_smtp_submission.py create mode 100644 src/client-bridge/uv.lock create mode 100644 src/e2e/src/__tests__/client-bridge.spec.ts create mode 100644 src/frontend/src/features/api/gen/models/config_retrieve200_clientbridgepublicconfi_g.ts create mode 100644 src/frontend/src/features/api/gen/models/rotate_password_response.ts create mode 100644 src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/client-bridge-integration-form.tsx diff --git a/.github/workflows/crowdin_download.yml b/.github/workflows/crowdin_download.yml index 7b934c087..2a365c18d 100644 --- a/.github/workflows/crowdin_download.yml +++ b/.github/workflows/crowdin_download.yml @@ -6,6 +6,9 @@ on: branches: - 'release/**' +permissions: + contents: read + jobs: synchronize-with-crowdin: diff --git a/.github/workflows/crowdin_upload.yml b/.github/workflows/crowdin_upload.yml index 27f983b9b..7a8c947ac 100644 --- a/.github/workflows/crowdin_upload.yml +++ b/.github/workflows/crowdin_upload.yml @@ -6,6 +6,9 @@ on: branches: - main +permissions: + contents: read + jobs: synchronize-with-crowdin: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 77f0c26dc..3e0ef7464 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,5 +1,8 @@ name: Build and Push Container Image +permissions: + contents: read + "on": workflow_call: inputs: diff --git a/.github/workflows/messages-ghcr.yml b/.github/workflows/messages-ghcr.yml index 44e49adef..77a767f9d 100644 --- a/.github/workflows/messages-ghcr.yml +++ b/.github/workflows/messages-ghcr.yml @@ -7,6 +7,9 @@ name: Build and publish OCI images tags: - 'v*' +permissions: + contents: read + jobs: docker-publish-mta-in: @@ -74,6 +77,19 @@ jobs: context: "src/backend" target: runtime-prod + docker-publish-client-bridge: + uses: ./.github/workflows/docker-publish.yml + permissions: + contents: read + packages: write + attestations: write + id-token: write + secrets: inherit + with: + image_name: "client-bridge" + context: "src/client-bridge" + target: runtime-prod + docker-publish-keycloak: uses: ./.github/workflows/docker-publish.yml permissions: diff --git a/.github/workflows/messages.yml b/.github/workflows/messages.yml index aa6804613..3edde37a4 100644 --- a/.github/workflows/messages.yml +++ b/.github/workflows/messages.yml @@ -8,6 +8,9 @@ name: Lint and tests branches: - '*' +permissions: + contents: read + env: COMPOSE_BAKE: true @@ -21,7 +24,7 @@ jobs: - name: Create env files run: make create-env-files - name: Run linting checks - run: make lint-back + run: make lint-check-back test-back: @@ -79,9 +82,7 @@ jobs: - name: Install frontend dependencies run: make install-frozen-front - name: Run frontend linting - run: make lint-front - - name: Run frontend check - run: make typecheck-front + run: make lint-check-front build-front: runs-on: ubuntu-latest @@ -95,6 +96,46 @@ jobs: - name: Build frontend run: make build-front + lint-client-bridge: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Create env files + run: make create-env-files + - name: Run client-bridge linting + run: make lint-check-client-bridge + + test-client-bridge: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Create env files + run: make create-env-files + - name: Run client-bridge tests + run: make test-client-bridge + + test-mta-in: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Create env files + run: make create-env-files + - name: Run mta-in tests + run: make test-mta-in + + test-socks-proxy: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Create env files + run: make create-env-files + - name: Run socks-proxy tests + run: make test-socks-proxy + check-api-state: runs-on: ubuntu-latest steps: diff --git a/Makefile b/Makefile index 3a12748bb..48c0ee5a9 100644 --- a/Makefile +++ b/Makefile @@ -61,6 +61,7 @@ create-env-files: \ env.d/development/frontend.local \ env.d/development/mta-in.local \ env.d/development/mta-out.local \ + env.d/development/client-bridge.local \ env.d/development/socks-proxy.local .PHONY: create-env-files @@ -134,7 +135,7 @@ logs: ## display all services logs (follow mode) .PHONY: logs start: ## start all development services - @$(COMPOSE) up --force-recreate --build -d frontend-dev backend-dev worker-dev mta-in --wait + @$(COMPOSE) up --force-recreate --build -d frontend-dev backend-dev worker-dev mta-in client-bridge --wait .PHONY: start start-minimal: ## start minimal services (backend, frontend, keycloak and DB) @@ -176,16 +177,18 @@ lint: ## run all linters lint: \ lint-back \ lint-front \ - typecheck-front \ lint-mta-in \ - lint-mta-out + lint-mta-out \ + lint-client-bridge .PHONY: lint lint-check: ## run all linters in check mode (no auto-fix) lint-check: \ lint-check-back \ - typecheck-front \ - lint-front + lint-check-front \ + lint-check-mta-in \ + lint-check-mta-out \ + lint-check-client-bridge .PHONY: lint-check lint-back: ## run back-end linters (with auto-fix) @@ -217,20 +220,42 @@ typecheck-front: ## run the frontend type checker @$(COMPOSE) run --rm frontend-tools npm run ts:check .PHONY: typecheck-front -lint-front: ## run the frontend linter +lint-front: ## run the frontend linter (typecheck + eslint) +lint-front: \ + typecheck-front @$(COMPOSE) run --rm frontend-tools npm run lint .PHONY: lint-front -lint-mta-in: ## lint mta-in python sources +lint-check-front: ## run the frontend linter in check mode (no auto-fix) +lint-check-front: lint-front +.PHONY: lint-check-front + +lint-mta-in: ## lint mta-in python sources (with auto-fix) $(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-in-test ruff format . #$(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-in-test ruff check . --fix #$(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-in-test pylint . .PHONY: lint-mta-in -lint-mta-out: ## lint mta-out python sources +lint-check-mta-in: ## lint mta-in python sources in check mode (no auto-fix) + $(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-in-test ruff format --check . +.PHONY: lint-check-mta-in + +lint-mta-out: ## lint mta-out python sources (with auto-fix) $(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-out-test ruff format . .PHONY: lint-mta-out +lint-check-mta-out: ## lint mta-out python sources in check mode (no auto-fix) + $(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-out-test ruff format --check . +.PHONY: lint-check-mta-out + +lint-client-bridge: ## lint client-bridge python sources (with auto-fix) + $(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true client-bridge-test ruff format . +.PHONY: lint-client-bridge + +lint-check-client-bridge: ## lint client-bridge python sources in check mode (no auto-fix) + $(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true client-bridge-test ruff format --check . +.PHONY: lint-check-client-bridge + # -- Tests test: ## run all tests @@ -239,6 +264,7 @@ test: \ test-front \ test-mta-in \ test-mta-out \ + test-client-bridge \ test-mpa \ test-socks-proxy .PHONY: test @@ -280,6 +306,10 @@ test-mta-out: ## run the mta-out tests @$(COMPOSE) run --build --rm mta-out-test .PHONY: test-mta-out +test-client-bridge: ## run the client-bridge tests + @$(COMPOSE) run --build --rm client-bridge-test +.PHONY: test-client-bridge + test-mpa: ## run the mpa tests @$(COMPOSE) run --build --rm mpa-test .PHONY: test-mpa @@ -331,21 +361,21 @@ logs-e2e: ## Show logs from e2e services test-e2e-bare: ## Run e2e tests in headless mode @echo "$(BLUE)\n\n| 🎭 Running E2E tests... \n$(RESET)" - $(COMPOSE_E2E) run --rm --service-ports runner npm run test -- $(args) + $(COMPOSE_E2E) run --rm --service-ports e2e-runner npm run test -- $(args) @echo "$(GREEN)> 🎭 E2E tests completed!$(RESET)\n" .PHONY: test-e2e-bare test-e2e-ui-bare: ## Run e2e tests in UI mode @echo "$(BLUE)\n\n| 🎭 Running E2E tests in UI mode... \n$(RESET)" # Note: || true allows graceful exit when user closes the UI - @$(COMPOSE_E2E) run --rm --service-ports runner npm run test:ui || true + @$(COMPOSE_E2E) run --rm --service-ports e2e-runner npm run test:ui || true @echo "$(GREEN)> 🎭 You killed the UI!$(RESET)\n" .PHONY: test-e2e-ui-bare test-e2e-dev-bare: ## Run e2e tests in UI mode with dev frontend @echo "$(BLUE)\n\n| 🎭 Running E2E tests in dev mode... \n$(RESET)" # Note: || true allows graceful exit when user closes the UI - E2E_PROFILE=dev $(COMPOSE_E2E) --profile dev run --rm --service-ports runner npm run test:ui || true + E2E_PROFILE=dev $(COMPOSE_E2E) --profile dev run --rm --service-ports e2e-runner npm run test:ui || true @echo "$(GREEN)> 🎭 You killed the UI!$(RESET)\n" .PHONY: test-e2e-dev-bare @@ -355,6 +385,7 @@ down-e2e: stop-e2e ## alias for stop-e2e demo-e2e: ## Populate the e2e database with demo data @echo "$(BLUE)\n\n| 📝 Bootstrapping E2E demo data... \n$(RESET)" @$(COMPOSE_E2E) run --rm backend python manage.py e2e_demo + @$(COMPOSE_E2E) run --rm backend python manage.py e2e_clientbridge .PHONY: demo-e2e start-e2e: ## Start e2e services (migrate, seed, etc.) @@ -579,3 +610,7 @@ deps-lock-mta-in: ## lock the dependencies deps-lock-mta-out: ## lock the dependencies @$(COMPOSE) run --rm --build mta-out-uv uv lock .PHONY: deps-lock-mta-out + +deps-lock-client-bridge: ## lock the dependencies + @$(COMPOSE) run --rm --build client-bridge-uv uv lock +.PHONY: deps-lock-client-bridge diff --git a/README.md b/README.md index 4db1d52f5..4b7e21b5b 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,11 @@ It features a [MTA](https://en.wikipedia.org/wiki/Message_transfer_agent) based * (soon) 👉 Assign threads to specific users ### Based on standards -* 🔑 OpenID Connect for all user accounts. Plug any identity provider, including Keycloak. -* 📬 SMTP in and out. -* ❌ No POP3 or IMAP client support, by design. We're building for the future, not the (unsecure) past! -* ✅ JMAP-inspired data model. Full support could be added. +* 🔑 OpenID Connect for all user accounts as the primary authentication method. Plug any identity provider, including Keycloak. +* 📬 SMTP in and out (server-to-server). +* ✅ JMAP-inspired data model. JMAP-compliant endpoint [in progress](https://github.com/suitenumerique/messages/pull/479). +* 📼 Optional IMAP and SMTP client access via the [client bridge](/src/client-bridge/), for users who prefer traditional email clients like Thunderbird or mobile phones. Uses app-specific passwords with configurable roles. + ### Self-host * 🚀 Messages is designed to be installed on the cloud or on your own servers. @@ -146,6 +147,8 @@ When running the project, the following services are available: | **SOCKS Proxy** | 8916 | SOCKS5 proxy | `user1` / `pwd1` | | **Mailcatcher (SMTP)** | 8917 | SMTP server | No auth required | | **MPA (Rspamd)** | 8918 | Spam filtering service | `password` | +| **Client Bridge (IMAP)** | 8919 | IMAP server for email clients | App-specific password | +| **Client Bridge (SMTP)** | 8920 | SMTP submission for email clients | App-specific password | ### OpenAPI client diff --git a/compose.yaml b/compose.yaml index dace51b3e..94e1425d1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -121,6 +121,7 @@ services: mailcatcher: condition: service_started + # Minimal backend service for development tasks that don't require all service dependencies backend-db: extends: backend-base profiles: @@ -268,6 +269,49 @@ services: target: uv pull_policy: build + client-bridge: + build: + context: src/client-bridge + target: runtime-prod + env_file: + - env.d/development/client-bridge.defaults + - env.d/development/client-bridge.local + ports: + - "8919:143" + - "8920:587" + depends_on: + - backend-dev + + client-bridge-test: + profiles: + - tools + build: + context: src/client-bridge + target: runtime-dev + env_file: + - env.d/development/client-bridge.defaults + - env.d/development/client-bridge.local + environment: + - EXEC_CMD=true + - IMAP_HOST=localhost + - IMAP_PORT=1143 + - SMTP_HOST=localhost + - SMTP_PORT=1587 + - MOCK_API_PORT=8765 + command: pytest -vvs tests/ + volumes: + - ./src/client-bridge:/app + + client-bridge-uv: + profiles: + - tools + volumes: + - ./src/client-bridge:/app + build: + context: src/client-bridge + target: uv + pull_policy: build + mta-out: build: context: src/mta-out diff --git a/docs/env.md b/docs/env.md index cc97db7a2..8db838b6f 100644 --- a/docs/env.md +++ b/docs/env.md @@ -314,6 +314,30 @@ without redeploying the frontend (the flag is pulled from | `FEATURE_AI_SUMMARY` | `False` | Default enabled mode for summary AI features | Required | | `FEATURE_AI_AUTOLABELS` | `False` | Default enabled mode for label AI features | Required | +### Client Bridge + +| Variable | Default | Description | Required | +|----------|---------|-------------|----------| +| `CLIENTBRIDGE_API_SECRET` | `""` | Shared secret for service-to-service auth between the client bridge and the backend. Must match the `CLIENTBRIDGE_API_SECRET` on the client-bridge service. | Required (if client bridge is enabled) | +| `CLIENTBRIDGE_SESSION_TIMEOUT` | `3600` | JWT session lifetime in seconds. IMAP/SMTP clients must re-authenticate when the token expires. | Optional | +| `CLIENTBRIDGE_PUBLIC_CONFIG` | `{}` | JSON object with IMAP/SMTP connection settings exposed to the frontend via `/api/v1.0/config/`. When set, the frontend displays these values in the integration setup form instead of inferring them from the browser's hostname. See [Client Bridge README](../src/client-bridge/README.md) for details. | Optional | + +`CLIENTBRIDGE_PUBLIC_CONFIG` accepts the following keys: + +| Key | Type | Description | Example | +|-----|------|-------------|---------| +| `imap_host` | string | Hostname for IMAP connections | `"imap.example.com"` | +| `imap_port` | integer | Port for IMAP connections | `993` | +| `imap_security` | string | Security mode for IMAP | `"SSL/TLS"` | +| `smtp_host` | string | Hostname for SMTP connections | `"smtp.example.com"` | +| `smtp_port` | integer | Port for SMTP connections | `587` | +| `smtp_security` | string | Security mode for SMTP | `"STARTTLS"` | + +Example: +```bash +CLIENTBRIDGE_PUBLIC_CONFIG='{"imap_host":"imap.example.com","imap_port":993,"imap_security":"SSL/TLS","smtp_host":"smtp.example.com","smtp_port":587,"smtp_security":"STARTTLS"}' +``` + ### Throttling Outbound message throttling limits the number of **external recipients** (recipients whose domain is not managed by this instance) that can be sent from a mailbox or maildomain within a time period, using simple fixed time windows. diff --git a/env.d/development/backend.defaults b/env.d/development/backend.defaults index 5e8f49087..29d235762 100644 --- a/env.d/development/backend.defaults +++ b/env.d/development/backend.defaults @@ -98,6 +98,11 @@ AI_MODEL= FEATURE_AI_SUMMARY=False FEATURE_AI_AUTOLABELS=False +# Client bridge (IMAP/SMTP email client access) +FEATURE_CLIENTBRIDGE=True +FEATURE_MAILBOX_ADMIN_CHANNELS=widget,client-bridge +CLIENTBRIDGE_PUBLIC_CONFIG={"imap_host":"localhost","imap_port":8919,"imap_security":"PLAIN","smtp_host":"localhost","smtp_port":8920,"smtp_security":"PLAIN"} + # Third-party services # Drive - https://github.com/suitenumerique/drive DRIVE_BASE_URL= diff --git a/env.d/development/backend.e2e b/env.d/development/backend.e2e index 31540122a..6385f2a5f 100644 --- a/env.d/development/backend.e2e +++ b/env.d/development/backend.e2e @@ -30,5 +30,9 @@ AWS_S3_DOMAIN_REPLACE= # Email configuration (use mailcatcher from main services or disable) EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend +# Client bridge (IMAP/SMTP email client access) +FEATURE_CLIENTBRIDGE=True +CLIENTBRIDGE_API_SECRET=e2e-shared-secret-clientbridge-at-least-32-bytes + # Debug DJANGO_DEBUG=True diff --git a/env.d/development/client-bridge.defaults b/env.d/development/client-bridge.defaults new file mode 100644 index 000000000..6e6a0f9a2 --- /dev/null +++ b/env.d/development/client-bridge.defaults @@ -0,0 +1,8 @@ +MESSAGES_API_BASE_URL=http://backend-dev:8000/api/v1.0/ +CLIENTBRIDGE_API_SECRET=my-shared-secret-clientbridge-at-least-32-bytes +ENABLE_IMAP=true +IMAP_HOST=0.0.0.0 +IMAP_PORT=143 +ENABLE_SMTP=true +SMTP_HOST=0.0.0.0 +SMTP_PORT=587 diff --git a/env.d/development/client-bridge.e2e b/env.d/development/client-bridge.e2e new file mode 100644 index 000000000..752c50ab5 --- /dev/null +++ b/env.d/development/client-bridge.e2e @@ -0,0 +1,2 @@ +MESSAGES_API_BASE_URL=http://backend:8000/api/v1.0/ +CLIENTBRIDGE_API_SECRET=e2e-shared-secret-clientbridge-at-least-32-bytes diff --git a/src/backend/.pylintrc b/src/backend/.pylintrc index d17b9f338..feaf3c6d6 100644 --- a/src/backend/.pylintrc +++ b/src/backend/.pylintrc @@ -23,7 +23,7 @@ jobs=0 # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. -load-plugins=pylint_django +load-plugins=pylint_django,pylint_custom # Pickle collected data for later comparisons. persistent=yes diff --git a/src/backend/README.md b/src/backend/README.md deleted file mode 100644 index a60ab976a..000000000 --- a/src/backend/README.md +++ /dev/null @@ -1 +0,0 @@ -# Messages backend diff --git a/src/backend/core/api/authentication.py b/src/backend/core/api/authentication.py index a4342b9cf..4d16bdada 100644 --- a/src/backend/core/api/authentication.py +++ b/src/backend/core/api/authentication.py @@ -1,22 +1,34 @@ """Authentication classes for service-to-service API calls. -Today this module ships a single scheme, ChannelApiKeyAuthentication, which -authenticates a request as an api_key Channel via the X-Channel-Id + X-API-Key -headers. New schemes (mTLS, signed JWT, OIDC client credentials, 
) should be -added here as additional BaseAuthentication subclasses that set -``request.auth`` to a Channel instance the same way. The downstream permission -layer (``HasChannelScope``) is scheme-agnostic — it only inspects -``request.auth``. +This module provides: + +- ``ServiceJWTAuthentication`` — shared base for JWT-based service auth. + Validates ``Authorization: Bearer `` tokens signed with HS256. + Subclasses override ``get_secret()`` and ``handle_payload()`` to customise + secret selection and post-validation logic. Used by MTA inbound. + +- ``ChannelJwtAuthentication`` — authenticates a Channel via a session JWT + in the ``X-Channel-Token`` header. The JWT must contain ``channel_id`` + and ``exp``. Scopes are read from the channel's database row (not the + JWT) and enforced per HTTP method. + +- ``ChannelApiKeyAuthentication`` — authenticates as an ``api_key`` Channel + via the ``X-Channel-Id`` + ``X-API-Key`` headers. + +The downstream permission layer (``HasChannelScope``) is scheme-agnostic — +it only inspects ``request.auth``. """ import hashlib -from secrets import compare_digest +import secrets as secrets_mod +from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.core.exceptions import ValidationError as DjangoValidationError from django.utils import timezone from django.utils.dateparse import parse_datetime +import jwt from rest_framework.authentication import BaseAuthentication from rest_framework.exceptions import AuthenticationFailed @@ -24,6 +36,154 @@ from core.enums import ChannelTypes +# --------------------------------------------------------------------------- +# Shared JWT base class +# --------------------------------------------------------------------------- + + +class ServiceJWTAuthentication(BaseAuthentication): + """Shared base for ``Authorization: Bearer `` service auth. + + The JWT must be HS256-signed and contain an ``exp`` claim. If the + request has a body, the JWT ``body_hash`` claim (SHA-256 hex digest) + is verified against the actual body — this is how the MTA-in service + proves request integrity and is reused by the client-bridge submit + flow. + + Subclasses MUST implement: + - ``get_secret(request)`` → the HMAC secret to verify the signature. + - ``handle_payload(request, payload)`` → ``(user, auth)`` tuple or + raise ``AuthenticationFailed``. + + Set ``require_body_hash = False`` on a subclass to skip the body-hash + check (useful for non-body endpoints like MTA check-recipients where + the body is JSON, not raw MIME). + """ + + require_body_hash = True + jwt_require_claims = ["exp"] + + def get_secret(self, request): + """Return the HMAC secret used to verify the JWT signature.""" + raise NotImplementedError + + def handle_payload(self, request, payload): + """Process a validated JWT payload. Return ``(user, auth)``.""" + raise NotImplementedError + + # -- public DRF interface ------------------------------------------------ + + def authenticate(self, request): + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return None + + secret = self.get_secret(request) + if not secret: + return None + + jwt_token = auth_header.split(" ", 1)[1] + + try: + payload = jwt.decode( + jwt_token, + secret, + algorithms=["HS256"], + options={ + "require": self.jwt_require_claims, + "verify_exp": True, + "verify_signature": True, + }, + ) + except jwt.ExpiredSignatureError as exc: + raise AuthenticationFailed("Token has expired.") from exc + except jwt.InvalidTokenError as exc: + raise AuthenticationFailed("Invalid token.") from exc + + # Body-hash integrity check + if self.require_body_hash and request.body: + expected_hash = payload.get("body_hash") + if not expected_hash: + raise AuthenticationFailed("Missing body_hash in token.") + actual_hash = hashlib.sha256(request.body).hexdigest() + if not secrets_mod.compare_digest(actual_hash, expected_hash): + raise AuthenticationFailed("Body hash mismatch.") + + return self.handle_payload(request, payload) + + def authenticate_header(self, request): + return 'Bearer realm="service"' + + +# --------------------------------------------------------------------------- +# Channel JWT authentication (X-Channel-Token) +# --------------------------------------------------------------------------- + + +class ChannelJwtAuthentication(BaseAuthentication): + """Authenticate a Channel via a session JWT in ``X-Channel-Token``. + + The JWT must be HS256-signed with ``settings.CLIENTBRIDGE_API_SECRET`` + and contain ``channel_id`` and ``exp``. All other claims are ignored — + scopes are always read from the channel's database row. + + On success ``request.user`` is set to ``channel.user`` (or + ``AnonymousUser`` when the channel has no owner) and ``request.auth`` + is set to the ``Channel`` instance. + + This class is **not** in ``DEFAULT_AUTHENTICATION_CLASSES``. Views + that accept JWT-authenticated channels must add it explicitly to their + ``authentication_classes`` and pair it with an appropriate scope + permission (e.g. ``channel_scope(ChannelScope.MESSAGES_SEND)``). + """ + + def authenticate(self, request): + token = request.headers.get("X-Channel-Token", "") + if not token: + return None + + secret = getattr(settings, "CLIENTBRIDGE_API_SECRET", "") + if not secret: + return None + + try: + payload = jwt.decode( + token, + secret, + algorithms=["HS256"], + options={"require": ["exp", "channel_id"]}, + ) + except jwt.ExpiredSignatureError as exc: + raise AuthenticationFailed( + "Session expired. Please re-authenticate." + ) from exc + except jwt.InvalidTokenError: + return None # Not our token — let other auth classes try + + channel_id = payload.get("channel_id") + if not channel_id: + return None + + try: + channel = models.Channel.objects.select_related( + "user", + "mailbox__domain", + ).get(id=channel_id) + except (models.Channel.DoesNotExist, ValueError) as exc: + raise AuthenticationFailed("Invalid channel.") from exc + + user = channel.user if channel.user_id else AnonymousUser() + return (user, channel) + + def authenticate_header(self, request): + return "X-Channel-Token" + + +# --------------------------------------------------------------------------- +# Channel API-key authentication (X-Channel-Id + X-API-Key) +# --------------------------------------------------------------------------- + + class ChannelApiKeyAuthentication(BaseAuthentication): """Authenticate as an api_key Channel via X-Channel-Id + X-API-Key. @@ -64,7 +224,7 @@ def authenticate(self, request): # match flips the boolean. matched = False for stored in stored_hashes: - if isinstance(stored, str) and compare_digest(stored, provided_hash): + if isinstance(stored, str) and secrets_mod.compare_digest(stored, provided_hash): matched = True if not matched: raise AuthenticationFailed("Invalid channel or API key.") diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 1776f2ad9..793952fad 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -266,6 +266,37 @@ "type": "boolean", "description": "Whether silent OIDC login is enabled", "readOnly": true + }, + "CLIENTBRIDGE_PUBLIC_CONFIG": { + "type": "object", + "description": "Client-bridge IMAP/SMTP connection settings for email clients.", + "properties": { + "imap_host": { + "type": "string", + "readOnly": true + }, + "imap_port": { + "type": "integer", + "readOnly": true + }, + "imap_security": { + "type": "string", + "readOnly": true + }, + "smtp_host": { + "type": "string", + "readOnly": true + }, + "smtp_port": { + "type": "integer", + "readOnly": true + }, + "smtp_security": { + "type": "string", + "readOnly": true + } + }, + "readOnly": true } }, "required": [ diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index b5e3f9cb5..e713a96d9 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -458,7 +458,7 @@ class HasChannelScope(permissions.BasePermission): ``Channel`` set by an authentication class like ``ChannelApiKeyAuthentication``) and checks that ``required_scope`` is listed in the channel's ``settings["scopes"]``. Additionally, if the required scope is in - ``CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY``, the calling channel must itself + ``CHANNEL_SCOPES_GLOBAL_ONLY``, the calling channel must itself be ``scope_level=global`` — this is the second of two enforcement points for global-only scopes (the first is the serializer at write time). @@ -485,7 +485,7 @@ def has_permission(self, request, view): if self.required_scope not in scopes: return False if ( - self.required_scope in enums.CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY + self.required_scope in enums.CHANNEL_SCOPES_GLOBAL_ONLY and channel.scope_level != enums.ChannelScopeLevel.GLOBAL ): return False @@ -498,7 +498,7 @@ def channel_scope(required_scope: str) -> type: DRF's ``permission_classes`` expects a list of classes, not instances, so we synthesize a tiny subclass per scope. Usage: - permission_classes = [channel_scope(ChannelApiKeyScope.MESSAGES_SEND)] + permission_classes = [channel_scope(ChannelScope.MESSAGES_SEND)] """ return type( f"HasChannelScope_{required_scope}", @@ -513,7 +513,7 @@ class IsGlobalChannelMixin: ``ChannelApiKeyAuthentication`` + ``channel_scope(...)``. Two-layer defense in depth: even when the required scope is in - ``CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY`` (and ``HasChannelScope`` already + ``CHANNEL_SCOPES_GLOBAL_ONLY`` (and ``HasChannelScope`` already rejects non-global callers), the view itself re-asserts the requirement so a future scope-set typo can't accidentally weaken the endpoint. The mixin's ``initial()`` runs after authentication+permission, so by the diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 6906f6846..771d4d415 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -3,6 +3,7 @@ import hashlib import json +import secrets import uuid from django.conf import settings @@ -17,6 +18,14 @@ from core import enums, models from core.mda.rfc5322 import extract_base64_images_from_html +# Base58 alphabet — no ambiguous characters (0, O, I, l) +BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + +def generate_base58_password(length=16): + """Generate a password using base58 alphabet. 16 chars ≈ 94 bits of entropy.""" + return "".join(secrets.choice(BASE58_ALPHABET) for _ in range(length)) + class CreateOnlyFieldsMixin: """Mixin that makes specified fields read-only on update (when instance exists). @@ -1667,6 +1676,93 @@ def create(self, validated_data): instance._generated_api_key = generated_api_key # noqa: SLF001 return instance + # Keys in settings that should be moved to encrypted_settings, per channel type. + # Note: client-bridge is NOT listed here — its passwords are always + # server-generated (on create or via rotate-password), never user-supplied. + ENCRYPTED_SETTINGS_KEYS = {} + + def _move_sensitive_settings(self, validated_data): + """Move sensitive keys from settings to encrypted_settings.""" + channel_type = validated_data.get("type") or ( + self.instance.type if self.instance else None + ) + keys_to_encrypt = self.ENCRYPTED_SETTINGS_KEYS.get(channel_type, []) + if not keys_to_encrypt: + return validated_data + + settings_data = validated_data.get("settings") + if not settings_data: + return validated_data + + extracted = { + key: settings_data[key] for key in keys_to_encrypt if key in settings_data + } + if extracted: + # Remove extracted keys from settings without mutating during iteration + for key in extracted: + del settings_data[key] + existing = (self.instance.encrypted_settings or {}) if self.instance else {} + validated_data["encrypted_settings"] = {**existing, **extracted} + + return validated_data + + @staticmethod + def _resolve_client_bridge_scopes(settings_data): + """Convert a client-bridge ``role`` shorthand into canonical scopes. + + Accepts either ``settings.role`` (convenience) or ``settings.scopes`` + (explicit). When both are given, ``scopes`` wins. The ``role`` key + is always removed — scopes are the single source of truth at rest. + """ + role = settings_data.pop("role", None) + if "scopes" not in settings_data: + role = role or "sender" + if role not in enums.CLIENT_BRIDGE_ROLE_SCOPES: + raise serializers.ValidationError( + { + "settings": { + "role": ( + f"Invalid role. Must be one of: " + f"{', '.join(enums.CLIENT_BRIDGE_ROLE_SCOPES)}" + ) + } + } + ) + settings_data["scopes"] = list(enums.CLIENT_BRIDGE_ROLE_SCOPES[role]) + + def create(self, validated_data): + if validated_data.get("type") == enums.ChannelTypes.CLIENT_BRIDGE: + # Server-generated password — never accept user-supplied ones + password = generate_base58_password() + settings_data = validated_data.get("settings") or {} + settings_data.pop("password", None) + validated_data["settings"] = settings_data + validated_data["encrypted_settings"] = {"password": password} + self._resolve_client_bridge_scopes(settings_data) + instance = super().create(validated_data) + # Stash password so the view can return it once + instance._generated_password = password # noqa: SLF001 # pylint: disable=protected-access + return instance + validated_data = self._move_sensitive_settings(validated_data) + return super().create(validated_data) + + def update(self, instance, validated_data): + channel_type = validated_data.get("type") or ( + instance.type if instance else None + ) + if channel_type == enums.ChannelTypes.CLIENT_BRIDGE: + # Strip any user-supplied password — passwords can only be + # changed via the dedicated rotate-password endpoint. + settings_data = validated_data.get("settings") or {} + settings_data.pop("password", None) + if "role" in settings_data or "scopes" in settings_data: + self._resolve_client_bridge_scopes(settings_data) + # Prevent encrypted_settings from being overwritten + validated_data.pop("encrypted_settings", None) + else: + validated_data = self._move_sensitive_settings(validated_data) + return super().update(instance, validated_data) + def validate_settings(self, value): """Validate settings, including tags if present.""" if not value: @@ -1786,7 +1882,7 @@ def _validate_api_key_scopes(self, attrs): which is the only way to airtight-block a mailbox admin from granting themselves a global-only scope via a settings-only PATCH. - - Every value must be a member of ``ChannelApiKeyScope``. + - Every value must be a member of ``ChannelScope``. - Global-only scopes (e.g. ``maildomains:create``) can only be requested when the channel itself has ``scope_level=global``. Since DRF clients cannot set scope_level, this always rejects @@ -1812,7 +1908,7 @@ def _validate_api_key_scopes(self, attrs): {"settings": "settings.scopes must be a list of strings."} ) - valid_values = {choice.value for choice in enums.ChannelApiKeyScope} + valid_values = {choice.value for choice in enums.ChannelScope} unknown = [s for s in raw_scopes if s not in valid_values] if unknown: raise serializers.ValidationError( @@ -1824,7 +1920,7 @@ def _validate_api_key_scopes(self, attrs): # USER. Global-only scopes are therefore never grantable via DRF. # If that ever changes, the viewset must still pass scope_level # explicitly on save. - global_only = enums.CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY + global_only = enums.CHANNEL_SCOPES_GLOBAL_ONLY if any(s in global_only for s in raw_scopes): raise serializers.ValidationError( {"settings": "One or more requested scopes are not permitted."} @@ -1905,6 +2001,13 @@ def validate(self, attrs): f"Allowed types: {', '.join(allowed_types)}" } ) + if ( + channel_type == enums.ChannelTypes.CLIENT_BRIDGE + and not settings.FEATURE_CLIENTBRIDGE + ): + raise serializers.ValidationError( + {"type": "Client bridge feature is not enabled."} + ) self._reject_caller_supplied_encrypted_keys(attrs) self._validate_api_key_scopes(attrs) self._validate_webhook_settings(attrs) diff --git a/src/backend/core/api/viewsets/channel.py b/src/backend/core/api/viewsets/channel.py index 20093209a..a6087fa1e 100644 --- a/src/backend/core/api/viewsets/channel.py +++ b/src/backend/core/api/viewsets/channel.py @@ -18,6 +18,7 @@ from core.enums import ChannelScopeLevel, ChannelTypes from .. import permissions, serializers +from ..serializers import generate_base58_password @extend_schema( @@ -103,7 +104,10 @@ def create(self, request, *args, **kwargs): """ serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - instance = serializer.save(**self.get_save_kwargs()) + save_kwargs = self.get_save_kwargs() + if serializer.validated_data.get("type") == "client-bridge": + save_kwargs["user"] = request.user + instance = serializer.save(**save_kwargs) data = serializer.data # Surface plaintext secrets exactly once on creation. Each generator diff --git a/src/backend/core/api/viewsets/client_bridge.py b/src/backend/core/api/viewsets/client_bridge.py new file mode 100644 index 000000000..32b9ab24b --- /dev/null +++ b/src/backend/core/api/viewsets/client_bridge.py @@ -0,0 +1,145 @@ +"""API views for client-bridge authentication.""" + +import hmac +import logging +import secrets +from datetime import UTC, datetime, timedelta + +from django.conf import settings + +import jwt +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.throttling import SimpleRateThrottle +from rest_framework.views import APIView + +from core import models +from core.enums import ChannelTypes + +logger = logging.getLogger(__name__) + +_INVALID_CREDENTIALS = {"detail": "Invalid credentials."} + + +class ClientBridgeAuthThrottle(SimpleRateThrottle): + """Throttle auth attempts by username to prevent brute-force attacks. + + All requests come from the client-bridge service so IP-based throttling + is useless. We key on the username (email) from the request body instead. + """ + + scope = "client_bridge_auth" + rate = "5/minute" + + def get_cache_key(self, request, view): + username = request.data.get("username") + if not username: + return None + return self.cache_format % { + "scope": self.scope, + "ident": username.lower(), + } + + +class ClientBridgeAuthView(APIView): + """Authenticate a client-bridge channel by email username and app-specific password. + + POST /api/v1.0/client-bridge/auth/ + Input: {"username": "", "password": "..."} + Returns: {"token": ""} + + The request must carry ``X-Service-Auth: Bearer `` + to prove it comes from the client-bridge service. + + The JWT is signed with CLIENTBRIDGE_API_SECRET and contains only + channel_id, mailbox_id, and expiration. Scopes are always read from + the channel's database row. The client-bridge passes the token as + X-Channel-Token on all subsequent requests. + """ + + authentication_classes = [] + permission_classes = [] + throttle_classes = [ClientBridgeAuthThrottle] + + @extend_schema(exclude=True) + def post(self, request): # pylint: disable=missing-function-docstring + # Validate service secret + if not settings.FEATURE_CLIENTBRIDGE: + return Response( + {"detail": "Client-bridge is disabled."}, + status=status.HTTP_403_FORBIDDEN, + ) + + header = request.headers.get("X-Service-Auth", "") + token_str = ( + header.removeprefix("Bearer ") if header.startswith("Bearer ") else header + ) + expected = settings.CLIENTBRIDGE_API_SECRET + if ( + not expected + or not token_str + or not secrets.compare_digest(token_str, expected) + ): + return Response( + {"detail": "Invalid service token."}, + status=status.HTTP_403_FORBIDDEN, + ) + + username = request.data.get("username") + password = request.data.get("password") + + if not username or not password: + return Response( + {"detail": "username and password are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Parse the email address + if "@" not in username: + return Response( + _INVALID_CREDENTIALS, + status=status.HTTP_401_UNAUTHORIZED, + ) + local_part, domain_name = username.rsplit("@", 1) + + # Look up the mailbox by email address + try: + mailbox = models.Mailbox.objects.get( + local_part=local_part, + domain__name=domain_name, + ) + except models.Mailbox.DoesNotExist: + return Response( + _INVALID_CREDENTIALS, + status=status.HTTP_401_UNAUTHORIZED, + ) + + # Try all client-bridge channels on this mailbox + channels = models.Channel.objects.filter( + mailbox=mailbox, type=ChannelTypes.CLIENT_BRIDGE + ) + for channel in channels: + stored_password = (channel.encrypted_settings or {}).get("password", "") + if stored_password and hmac.compare_digest(stored_password, password): + timeout = settings.CLIENTBRIDGE_SESSION_TIMEOUT + expires_at = datetime.now(UTC) + timedelta(seconds=timeout) + channel_token = jwt.encode( + { + "channel_id": str(channel.id), + "mailbox_id": str(mailbox.id), + "mailbox_email": username, + "exp": expires_at, + }, + settings.CLIENTBRIDGE_API_SECRET, + algorithm="HS256", + ) + return Response( + {"token": channel_token}, + status=status.HTTP_200_OK, + ) + + return Response( + _INVALID_CREDENTIALS, + status=status.HTTP_401_UNAUTHORIZED, + ) diff --git a/src/backend/core/api/viewsets/config.py b/src/backend/core/api/viewsets/config.py index 1f2f9ee39..90ff90fdb 100644 --- a/src/backend/core/api/viewsets/config.py +++ b/src/backend/core/api/viewsets/config.py @@ -136,6 +136,37 @@ class ConfigView(drf.views.APIView): "description": "Whether silent OIDC login is enabled", "readOnly": True, }, + "CLIENTBRIDGE_PUBLIC_CONFIG": { + "type": "object", + "description": "Client-bridge IMAP/SMTP connection settings for email clients.", + "properties": { + "imap_host": { + "type": "string", + "readOnly": True, + }, + "imap_port": { + "type": "integer", + "readOnly": True, + }, + "imap_security": { + "type": "string", + "readOnly": True, + }, + "smtp_host": { + "type": "string", + "readOnly": True, + }, + "smtp_port": { + "type": "integer", + "readOnly": True, + }, + "smtp_security": { + "type": "string", + "readOnly": True, + }, + }, + "readOnly": True, + }, }, "required": [ "ENVIRONMENT", @@ -191,7 +222,7 @@ def get(self, request): dict_settings = {} for setting in array_settings: if hasattr(settings, setting): - dict_settings[setting] = getattr(settings, setting) + dict_settings[setting] = getattr(settings, setting) # pylint: disable=getattr-on-settings # AI Features dict_settings["AI_ENABLED"] = is_ai_enabled() @@ -211,4 +242,10 @@ def get(self, request): } ) + # Client-bridge connection settings + if settings.FEATURE_CLIENTBRIDGE and settings.CLIENTBRIDGE_PUBLIC_CONFIG: + dict_settings["CLIENTBRIDGE_PUBLIC_CONFIG"] = ( + settings.CLIENTBRIDGE_PUBLIC_CONFIG + ) + return drf.response.Response(dict_settings) diff --git a/src/backend/core/api/viewsets/inbound/mta.py b/src/backend/core/api/viewsets/inbound/mta.py index 07bde902a..57e03a0b0 100644 --- a/src/backend/core/api/viewsets/inbound/mta.py +++ b/src/backend/core/api/viewsets/inbound/mta.py @@ -1,70 +1,40 @@ """MTA channel implementation for handling email delivery.""" -import hashlib import logging -import secrets from django.conf import settings -import jwt from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets -from rest_framework.authentication import BaseAuthentication from rest_framework.decorators import action -from rest_framework.exceptions import AuthenticationFailed from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from core import models +from core.api.authentication import ServiceJWTAuthentication from core.mda.inbound import check_local_recipients, deliver_inbound_message from core.mda.rfc5322 import EmailParseError, parse_email_message logger = logging.getLogger(__name__) -class MTAJWTAuthentication(BaseAuthentication): - """ - Custom authentication for MTA endpoints using JWT tokens with email hash validation. - Returns None or (user, auth) - """ - - def authenticate(self, request): - auth_header = request.headers.get("Authorization") - if not auth_header: - return None +class MTAJWTAuthentication(ServiceJWTAuthentication): + """MTA-specific JWT authentication. - try: - jwt_token = auth_header.split(" ")[1] - payload = jwt.decode( - jwt_token, - settings.MDA_API_SECRET, - algorithms=["HS256"], - options={ - "require": ["exp"], - "verify_exp": True, - "verify_signature": True, - }, - ) - - if not payload.get("exp"): - raise jwt.InvalidTokenError("Missing expiration time") - - # Validate email hash if there's a body - if request.body: - body_hash = hashlib.sha256(request.body).hexdigest() - if not secrets.compare_digest(body_hash, payload["body_hash"]): - raise jwt.InvalidTokenError("Invalid email hash") + Inherits body-hash validation and JWT verification from + ``ServiceJWTAuthentication``. The JWT is signed with + ``settings.MDA_API_SECRET`` and the payload is returned as + ``request.auth`` (a dict containing ``original_recipients`` etc.). + """ - service_account = models.User() - return (service_account, payload) + def get_secret(self, request): + return settings.MDA_API_SECRET - except (jwt.ExpiredSignatureError, jwt.InvalidTokenError) as e: - raise AuthenticationFailed("Invalid token") from e - except (IndexError, KeyError) as e: - raise AuthenticationFailed("Invalid token header or payload") from e + def handle_payload(self, request, payload): + service_account = models.User() + return (service_account, payload) def authenticate_header(self, request): - """Return the header to be used in the WWW-Authenticate response header.""" return 'Bearer realm="MTA"' diff --git a/src/backend/core/api/viewsets/metrics.py b/src/backend/core/api/viewsets/metrics.py index 3ac2ad6a0..916592ed0 100644 --- a/src/backend/core/api/viewsets/metrics.py +++ b/src/backend/core/api/viewsets/metrics.py @@ -15,7 +15,7 @@ from core.api.authentication import ChannelApiKeyAuthentication from core.api.permissions import IsGlobalChannelMixin, channel_scope -from core.enums import ChannelApiKeyScope +from core.enums import ChannelScope from core.models import ( Attachment, Blob, @@ -39,14 +39,14 @@ class MailDomainUsersMetricsApiView(IsGlobalChannelMixin, APIView): """ API view to expose MailDomain Users custom metrics. Global-only. - ``METRICS_READ`` is in CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY (so the + ``METRICS_READ`` is in CHANNEL_SCOPES_GLOBAL_ONLY (so the permission layer rejects non-global api_key channels), and ``IsGlobalChannelMixin`` re-asserts the same invariant in the view as a second defense-in-depth layer. """ authentication_classes = [ChannelApiKeyAuthentication] - permission_classes = [channel_scope(ChannelApiKeyScope.METRICS_READ)] + permission_classes = [channel_scope(ChannelScope.METRICS_READ)] @extend_schema(exclude=True) def get(self, request): @@ -208,7 +208,7 @@ class MailboxUsageMetricsApiView(IsGlobalChannelMixin, APIView): """ authentication_classes = [ChannelApiKeyAuthentication] - permission_classes = [channel_scope(ChannelApiKeyScope.METRICS_READ)] + permission_classes = [channel_scope(ChannelScope.METRICS_READ)] @extend_schema(exclude=True) def get(self, request): diff --git a/src/backend/core/api/viewsets/provisioning.py b/src/backend/core/api/viewsets/provisioning.py index 78eac30a0..f2ebacac4 100644 --- a/src/backend/core/api/viewsets/provisioning.py +++ b/src/backend/core/api/viewsets/provisioning.py @@ -18,7 +18,7 @@ MailboxLightSerializer, ProvisioningMailDomainSerializer, ) -from core.enums import ChannelApiKeyScope, MailboxRoleChoices +from core.enums import ChannelScope, MailboxRoleChoices logger = logging.getLogger(__name__) @@ -27,7 +27,7 @@ class ProvisioningMailDomainView(IsGlobalChannelMixin, APIView): """Provision mail domains from DeployCenter webhooks. Global-only.""" authentication_classes = [ChannelApiKeyAuthentication] - permission_classes = [channel_scope(ChannelApiKeyScope.MAILDOMAINS_CREATE)] + permission_classes = [channel_scope(ChannelScope.MAILDOMAINS_CREATE)] @extend_schema(exclude=True) def post(self, request): @@ -134,7 +134,7 @@ class ProvisioningMailboxView(IsGlobalChannelMixin, APIView): """ authentication_classes = [ChannelApiKeyAuthentication] - permission_classes = [channel_scope(ChannelApiKeyScope.MAILBOXES_READ)] + permission_classes = [channel_scope(ChannelScope.MAILBOXES_READ)] @extend_schema(exclude=True) def get(self, request): diff --git a/src/backend/core/api/viewsets/send.py b/src/backend/core/api/viewsets/send.py index a84e15477..5724bbf57 100644 --- a/src/backend/core/api/viewsets/send.py +++ b/src/backend/core/api/viewsets/send.py @@ -15,6 +15,7 @@ from core import models from core.api.viewsets.task import register_task_owner +from core.enums import ChannelScope from core.mda.outbound import prepare_outbound_message from core.mda.outbound_tasks import send_message_task @@ -77,6 +78,15 @@ class SendMessageView(APIView): def post(self, request): """Send a draft message identified by messageId.""" + # If authenticated via a channel (client-bridge or api_key), enforce + # the messages:send scope. Defense-in-depth for the /send/ endpoint. + if isinstance(request.auth, models.Channel): + scopes = (request.auth.settings or {}).get("scopes") or [] + if ChannelScope.MESSAGES_SEND not in scopes: + raise drf_exceptions.PermissionDenied( + "This channel does not have send access." + ) + serializer = serializers.SendMessageSerializer(data=request.data) serializer.is_valid(raise_exception=True) message_id = serializer.validated_data.get("messageId") diff --git a/src/backend/core/api/viewsets/submit.py b/src/backend/core/api/viewsets/submit.py index 605d31169..4c5bd0ee7 100644 --- a/src/backend/core/api/viewsets/submit.py +++ b/src/backend/core/api/viewsets/submit.py @@ -5,6 +5,12 @@ Creates a Message via the inbound pipeline (with ``is_outbound=True``), then runs ``prepare_outbound_message`` synchronously (DKIM signing, blob creation) and dispatches SMTP delivery asynchronously via Celery. + +Supports two authentication methods: +- ``ChannelApiKeyAuthentication``: API key channel with ``messages:send`` + scope. Mailbox is identified by UUID in the ``X-Mail-From`` header. +- ``ChannelJwtAuthentication``: channel session JWT via + ``X-Channel-Token``. Mailbox is resolved from the authenticated channel. """ import logging @@ -14,13 +20,13 @@ from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import BasePermission from rest_framework.response import Response from rest_framework.views import APIView from core import models -from core.api.authentication import ChannelApiKeyAuthentication -from core.api.permissions import channel_scope -from core.enums import MAILBOX_ROLES_CAN_SEND, ChannelApiKeyScope +from core.api.authentication import ChannelApiKeyAuthentication, ChannelJwtAuthentication +from core.enums import MAILBOX_ROLES_CAN_SEND, ChannelScope, ChannelTypes from core.mda.inbound_create import _create_message_from_inbound from core.mda.outbound import prepare_outbound_message from core.mda.outbound_tasks import send_message_task @@ -29,16 +35,37 @@ logger = logging.getLogger(__name__) +class CanSubmitMessage(BasePermission): + """Allow the request if the authenticated channel has ``messages:send``. + + Works identically for both api_key and client-bridge channels — both + store scopes in ``channel.settings["scopes"]``. + """ + + def has_permission(self, request, view): + channel = request.auth + if not isinstance(channel, models.Channel): + return False + scopes = (channel.settings or {}).get("scopes") or [] + return ChannelScope.MESSAGES_SEND in scopes + + class SubmitRawEmailView(APIView): """Submit a pre-composed RFC 5322 email for delivery from a mailbox. POST /api/v1.0/submit/ Content-Type: message/rfc822 - Headers: - X-Channel-Id: (api_key channel with messages:send scope) + + **API key authentication** (``ChannelApiKeyAuthentication``): + X-Channel-Id: X-API-Key: - X-Mail-From: (UUID of the sending mailbox) - X-Rcpt-To: [,] (comma-separated recipient addresses) + X-Mail-From: + X-Rcpt-To: [,] + + **Channel JWT authentication** (``ChannelJwtAuthentication``): + X-Channel-Token: + X-Mail-From: (optional — defaults to channel mailbox) + X-Rcpt-To: [,] The endpoint creates a Message record, DKIM-signs the raw MIME synchronously, and dispatches SMTP delivery via Celery. @@ -46,46 +73,58 @@ class SubmitRawEmailView(APIView): Returns: ``{"message_id": "<
>", "status": "accepted"}`` (HTTP 202). """ - authentication_classes = [ChannelApiKeyAuthentication] - permission_classes = [channel_scope(ChannelApiKeyScope.MESSAGES_SEND)] + authentication_classes = [ + ChannelApiKeyAuthentication, + ChannelJwtAuthentication, + ] + permission_classes = [CanSubmitMessage] @extend_schema(exclude=True) def post(self, request): """Accept a raw MIME message, create a Message, sign, and dispatch.""" - mailbox_id = request.META.get("HTTP_X_MAIL_FROM") + channel = request.auth + is_client_bridge = isinstance(channel, models.Channel) and channel.type == ChannelTypes.CLIENT_BRIDGE + + # -- Resolve mailbox -------------------------------------------------- + if is_client_bridge: + mailbox = channel.mailbox + else: + mailbox_id = request.META.get("HTTP_X_MAIL_FROM") + if not mailbox_id: + return Response( + {"detail": "Missing required headers: X-Mail-From, X-Rcpt-To."}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + mailbox = models.Mailbox.objects.select_related("domain").get( + id=mailbox_id + ) + except ( + models.Mailbox.DoesNotExist, + ValueError, + DjangoValidationError, + ): + return Response( + {"detail": "Mailbox not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Enforce api_key scope bounds (mailbox/maildomain/user level). + if not channel.api_key_covers( + mailbox=mailbox, mailbox_roles=MAILBOX_ROLES_CAN_SEND + ): + raise PermissionDenied( + "API key is not authorized to send as this mailbox." + ) + + # -- Validate envelope ------------------------------------------------ rcpt_to_header = request.META.get("HTTP_X_RCPT_TO") - - if not mailbox_id or not rcpt_to_header: + if not rcpt_to_header: return Response( {"detail": "Missing required headers: X-Mail-From, X-Rcpt-To."}, status=status.HTTP_400_BAD_REQUEST, ) - # Resolve mailbox - try: - mailbox = models.Mailbox.objects.select_related("domain").get(id=mailbox_id) - except (models.Mailbox.DoesNotExist, ValueError, DjangoValidationError): - return Response( - {"detail": "Mailbox not found."}, - status=status.HTTP_404_NOT_FOUND, - ) - - # Enforce the api_key channel's resource scope. A scope_level=mailbox - # credential can only send as that mailbox; a scope_level=maildomain - # credential only within that domain; a global credential is - # unrestricted. For scope_level=user we additionally require the - # target user to have a SENDER-or-better role on the mailbox via - # MailboxAccess — without this, a viewer-only user could mint a - # personal api_key and submit messages. - if not request.auth.api_key_covers( - mailbox=mailbox, mailbox_roles=MAILBOX_ROLES_CAN_SEND - ): - raise PermissionDenied("API key is not authorized to send as this mailbox.") - - # Parse envelope recipients. _create_message_from_inbound creates - # MessageRecipient rows from MIME To/Cc headers; any address that - # appears only in X-Rcpt-To (not in MIME headers) is added as BCC - # after message creation — this is how SMTP BCC works. recipient_emails = [ addr.strip() for addr in rcpt_to_header.split(",") if addr.strip() ] @@ -102,7 +141,7 @@ def post(self, request): status=status.HTTP_400_BAD_REQUEST, ) - # Parse to validate structure + # -- Parse and validate ----------------------------------------------- try: parsed = parse_email_message(raw_mime) except EmailParseError: @@ -111,7 +150,6 @@ def post(self, request): status=status.HTTP_400_BAD_REQUEST, ) - # Validate sender matches the mailbox sender_email = (parsed.get("from") or {}).get("email", "") mailbox_email = str(mailbox) if sender_email.lower() != mailbox_email.lower(): @@ -125,14 +163,13 @@ def post(self, request): status=status.HTTP_403_FORBIDDEN, ) - # Create thread, contacts, message, and recipients from the parsed email. - # is_outbound=True skips blob creation (handled by prepare_outbound_message - # with DKIM) and AI features. + # -- Create message --------------------------------------------------- message = _create_message_from_inbound( recipient_email=mailbox_email, parsed_email=parsed, raw_data=raw_mime, mailbox=mailbox, + channel=channel if isinstance(channel, models.Channel) else None, is_outbound=True, ) if not message: @@ -141,10 +178,7 @@ def post(self, request): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - # Add envelope-only recipients as BCC. _create_message_from_inbound - # creates MessageRecipient rows from the MIME To/Cc/Bcc headers, but - # true BCC recipients appear only in the envelope (X-Rcpt-To), never - # in the MIME headers — that's how BCC works in SMTP. + # -- Envelope-only BCC recipients ------------------------------------- mime_recipients = { e.lower() for e in message.recipients.values_list("contact__email", flat=True) @@ -165,15 +199,14 @@ def post(self, request): except Exception: # pylint: disable=broad-exception-caught logger.warning("Failed to add BCC recipient (masked)") - # Synchronous: validate recipients, throttle, DKIM sign, create blob. - # This is a one-shot API — clean up on any failure so no orphan - # draft remains. + # -- DKIM sign and prepare -------------------------------------------- try: prepared = prepare_outbound_message( mailbox, message, "", "", + user=request.user if request.user.is_authenticated else None, raw_mime=raw_mime, ) except Exception: @@ -187,7 +220,7 @@ def post(self, request): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - # Dispatch async SMTP delivery + # -- Dispatch async SMTP delivery ------------------------------------- send_message_task.delay(str(message.id)) return Response( diff --git a/src/backend/core/enums.py b/src/backend/core/enums.py index 7caffe5d2..934b1032d 100644 --- a/src/backend/core/enums.py +++ b/src/backend/core/enums.py @@ -23,6 +23,18 @@ class MailboxRoleChoices(models.IntegerChoices): ADMIN = 4, "admin" +# Legacy client-bridge role names. Only used for backwards-compatible +# serializer input (``settings.role``) and the auth-endpoint JWT ``role`` +# claim. Internally every channel — api_key *and* client-bridge — now +# stores ``settings["scopes"]`` and all permission checks use scopes. +CLIENT_BRIDGE_ROLE_SCOPES = { + "reader": ("messages:read",), + "editor": ("messages:read", "messages:edit"), + "sender": ("messages:read", "messages:edit", "messages:send"), + "sender_only": ("messages:send",), +} + + # Mailbox role groups for permission checks MAILBOX_ROLES_CAN_EDIT = [ MailboxRoleChoices.EDITOR, @@ -174,6 +186,7 @@ class ChannelTypes(StrEnum): WIDGET = "widget" API_KEY = "api_key" WEBHOOK = "webhook" + CLIENT_BRIDGE = "client-bridge" class WebhookEvents(StrEnum): @@ -187,22 +200,22 @@ class WebhookEvents(StrEnum): MESSAGE_SENT = "message.sent" -class ChannelApiKeyScope(models.TextChoices): - """Capability scopes granted to an api_key Channel. +class ChannelScope(models.TextChoices): + """Capability scopes granted to a Channel (api_key or client-bridge). Stored as a list of string values in Channel.settings["scopes"] and enforced by the serializer + HasChannelScope permission at the API layer. Adding a new scope is a Python-only change (no DB choices, no migration). A credential's blast radius for any scope is automatically bounded by its - channel's scope_level + target FK: a scope_level=mailbox api_key can only + channel's scope_level + target FK: a scope_level=mailbox channel can only act on that mailbox, regardless of which scopes it holds. WRITE vs CREATE distinction: ``*_WRITE`` scopes modify an object the channel already has resource-scope access to (e.g. archiving a thread in a mailbox-scope channel's mailbox). ``*_CREATE`` scopes mint a brand-new top-level resource, which is an escalation — these are global-only and - listed in ``CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY``. Most resources only + listed in ``CHANNEL_SCOPES_GLOBAL_ONLY``. Most resources only need WRITE because their "create" never escalates; only mailboxes and maildomains have a meaningful _CREATE counterpart. @@ -215,6 +228,8 @@ class ChannelApiKeyScope(models.TextChoices): METRICS_READ = "metrics:read", "Read usage metrics" MAILBOXES_READ = "mailboxes:read", "Read mailboxes (and their users/roles)" + MESSAGES_READ = "messages:read", "Read messages and threads" + MESSAGES_EDIT = "messages:edit", "Edit messages (flags, labels, drafts)" MESSAGES_SEND = "messages:send", "Send outbound messages" MAILDOMAINS_CREATE = "maildomains:create", "Create new maildomains" @@ -228,7 +243,6 @@ class ChannelApiKeyScope(models.TextChoices): # LABELS_READ = "labels:read", "Read labels" # CONTACTS_READ = "contacts:read", "Read contacts" # THREADS_READ = "threads:read", "Read thread metadata" - # MESSAGES_READ = "messages:read", "Read message metadata" # MESSAGES_READ_BODY = "messages:read.body", "Read message bodies" # ATTACHMENTS_READ = "attachments:read", "Read attachments" # BLOBS_READ = "blobs:read", "Read raw MIME blobs" @@ -245,15 +259,16 @@ class ChannelApiKeyScope(models.TextChoices): # MAILBOXES_CREATE = "mailboxes:create", "Create new mailboxes" + # Scopes that can only be granted to / used by a scope_level=global Channel. # Two enforcement points use this set: # - the serializer (write time) rejects non-global channels asking for these # - HasChannelScope (request time) rejects requests where the calling # channel is not global but a global-only scope is required -CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY = frozenset( +CHANNEL_SCOPES_GLOBAL_ONLY = frozenset( { - ChannelApiKeyScope.METRICS_READ.value, - ChannelApiKeyScope.MAILDOMAINS_CREATE.value, + ChannelScope.METRICS_READ.value, + ChannelScope.MAILDOMAINS_CREATE.value, } ) diff --git a/src/backend/core/mda/outbound.py b/src/backend/core/mda/outbound.py index 49f2e0adb..543c432a1 100644 --- a/src/backend/core/mda/outbound.py +++ b/src/backend/core/mda/outbound.py @@ -388,13 +388,56 @@ def prepare_outbound_message( mailbox_sender, message, extra_update_fields=("mime_id", "has_attachments") ) - # Clean up the draft blob and the attachment blobs - if draft_blob: - draft_blob.delete() - for attachment in message.attachments.all(): - if attachment.blob: - attachment.blob.delete() - attachment.delete() + +def _sign_and_store( + mailbox_sender: models.Mailbox, + message: models.Message, + raw_mime: bytes, + has_attachments: Optional[bool] = None, +) -> bool: + """Sign raw MIME with DKIM, store as a blob, and finalize the message. + + Shared by both the web compose path and the client-bridge raw MIME path. + """ + # Sign the message with DKIM + dkim_signature_header: Optional[bytes] = sign_message_dkim( + raw_mime_message=raw_mime, maildomain=mailbox_sender.domain + ) + + raw_mime_signed = raw_mime + if dkim_signature_header: + raw_mime_signed = dkim_signature_header + b"\r\n" + raw_mime + + # Create a blob to store the raw MIME content + blob = mailbox_sender.create_blob( + content=raw_mime_signed, + content_type="message/rfc822", + ) + + message.blob = blob + message.is_draft = False + message.draft_blob = None + message.created_at = timezone.now() + message.updated_at = timezone.now() + + update_fields = [ + "updated_at", + "blob", + "is_draft", + "sender_user", + "draft_blob", + "created_at", + ] + + if has_attachments is not None: + message.has_attachments = has_attachments + update_fields.append("has_attachments") + + if message.mime_id: + update_fields.append("mime_id") + + message.save(update_fields=update_fields) + message.thread.update_stats() return True diff --git a/src/backend/core/services/search/search.py b/src/backend/core/services/search/search.py index e9b7a75b1..ec9895815 100644 --- a/src/backend/core/services/search/search.py +++ b/src/backend/core/services/search/search.py @@ -37,7 +37,7 @@ def search_threads( # pylint: disable=too-many-branches Dictionary with thread search results: {"threads": [...], "total": int, "from": int, "size": int} """ # Check if OpenSearch is enabled - if not getattr(settings, "OPENSEARCH_INDEX_THREADS", True): + if not settings.OPENSEARCH_INDEX_THREADS: logger.debug("OpenSearch search is disabled, returning empty results") return {"threads": [], "total": 0, "from": from_offset, "size": size} diff --git a/src/backend/core/tests/api/test_channel_api_key_auth.py b/src/backend/core/tests/api/test_channel_api_key_auth.py index 7d1a9e446..ba08a9bb3 100644 --- a/src/backend/core/tests/api/test_channel_api_key_auth.py +++ b/src/backend/core/tests/api/test_channel_api_key_auth.py @@ -15,13 +15,13 @@ import pytest from core import models -from core.enums import ChannelApiKeyScope, ChannelScopeLevel +from core.enums import ChannelScope, ChannelScopeLevel from core.factories import MailboxFactory, make_api_key_channel SUBMIT_URL = "/api/v1.0/submit/" -def _make_channel(scopes=(ChannelApiKeyScope.MESSAGES_SEND.value,), **kwargs): +def _make_channel(scopes=(ChannelScope.MESSAGES_SEND.value,), **kwargs): """Wrapper around the shared factory pre-loaded with the auth-class test default scope (messages:send). Callers can still override ``scopes`` and any other kwarg.""" @@ -75,7 +75,7 @@ def test_non_api_key_channel_cannot_authenticate(self, client): type="widget", scope_level=ChannelScopeLevel.GLOBAL, encrypted_settings={"api_key_hashes": [digest]}, - settings={"scopes": [ChannelApiKeyScope.MESSAGES_SEND.value]}, + settings={"scopes": [ChannelScope.MESSAGES_SEND.value]}, ) channel.save() response = client.post( @@ -119,19 +119,19 @@ class TestHasChannelScope: """Direct tests for HasChannelScope.has_permission.""" def test_scope_present(self, rf): - channel, _ = _make_channel(scopes=(ChannelApiKeyScope.MESSAGES_SEND.value,)) + channel, _ = _make_channel(scopes=(ChannelScope.MESSAGES_SEND.value,)) from core.api.permissions import channel_scope - perm_class = channel_scope(ChannelApiKeyScope.MESSAGES_SEND) + perm_class = channel_scope(ChannelScope.MESSAGES_SEND) request = rf.post("/") request.auth = channel assert perm_class().has_permission(request, None) is True def test_scope_absent(self, rf): - channel, _ = _make_channel(scopes=(ChannelApiKeyScope.METRICS_READ.value,)) + channel, _ = _make_channel(scopes=(ChannelScope.METRICS_READ.value,)) from core.api.permissions import channel_scope - perm_class = channel_scope(ChannelApiKeyScope.MESSAGES_SEND) + perm_class = channel_scope(ChannelScope.MESSAGES_SEND) request = rf.post("/") request.auth = channel assert perm_class().has_permission(request, None) is False @@ -139,7 +139,7 @@ def test_scope_absent(self, rf): def test_auth_not_a_channel(self, rf): from core.api.permissions import channel_scope - perm_class = channel_scope(ChannelApiKeyScope.MESSAGES_SEND) + perm_class = channel_scope(ChannelScope.MESSAGES_SEND) request = rf.post("/") request.auth = None assert perm_class().has_permission(request, None) is False diff --git a/src/backend/core/tests/api/test_channel_scope_level.py b/src/backend/core/tests/api/test_channel_scope_level.py index e8e49fc57..a178772df 100644 --- a/src/backend/core/tests/api/test_channel_scope_level.py +++ b/src/backend/core/tests/api/test_channel_scope_level.py @@ -319,7 +319,7 @@ def test_mailbox_admin_cannot_request_global_only_scope(self, api_client): assert response.status_code == 400, response.content def test_mailbox_admin_cannot_request_metrics_read_scope(self, api_client): - """metrics:read is in CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY too.""" + """metrics:read is in CHANNEL_SCOPES_GLOBAL_ONLY too.""" import uuid from core.enums import MailboxRoleChoices @@ -472,7 +472,7 @@ def test_patch_cannot_grant_global_only_scope(self, api_client): assert channel.settings["scopes"] == ["messages:send"] def test_patch_cannot_inject_unknown_scope(self, api_client): - """PATCH must also reject scope strings outside ChannelApiKeyScope.""" + """PATCH must also reject scope strings outside ChannelScope.""" mailbox = MailboxFactory() channel = self._create_with_send_scope(api_client, mailbox) @@ -517,7 +517,7 @@ def test_patch_legitimate_scope_change_works(self, api_client): }, format="json", ) - # mailboxes:read isn't in CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY, so + # mailboxes:read isn't in CHANNEL_SCOPES_GLOBAL_ONLY, so # it's grantable on a mailbox-scope channel. assert response.status_code == 200, response.content channel.refresh_from_db() diff --git a/src/backend/core/tests/api/test_client_bridge.py b/src/backend/core/tests/api/test_client_bridge.py new file mode 100644 index 000000000..cdfa8b52a --- /dev/null +++ b/src/backend/core/tests/api/test_client_bridge.py @@ -0,0 +1,910 @@ +"""Tests for the client-bridge API endpoints (auth and submit).""" + +# pylint: disable=redefined-outer-name,too-many-lines + +import uuid +from datetime import UTC, datetime, timedelta +from unittest.mock import patch + +from django.test import override_settings + +import jwt +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from core import models +from core.enums import CLIENT_BRIDGE_ROLE_SCOPES, ChannelScope, ChannelTypes +from core.factories import ChannelFactory, MailboxFactory, UserFactory + +SERVICE_SECRET = "my-shared-secret-clientbridge-at-least-32-bytes" + +# Shorthand scopes for parametrized tests +SCOPES_READER = list(CLIENT_BRIDGE_ROLE_SCOPES["reader"]) +SCOPES_EDITOR = list(CLIENT_BRIDGE_ROLE_SCOPES["editor"]) +SCOPES_SENDER = list(CLIENT_BRIDGE_ROLE_SCOPES["sender"]) +SCOPES_SEND_ONLY = list(CLIENT_BRIDGE_ROLE_SCOPES["sender_only"]) + + +def _make_jwt( + channel, + mailbox, + secret=SERVICE_SECRET, + expires_in=3600, + **extra_claims, +): + """Generate a JWT token for testing, matching what ClientBridgeAuthView issues.""" + payload = { + "channel_id": str(channel.id), + "mailbox_id": str(mailbox.id), + "mailbox_email": str(mailbox), + "exp": datetime.now(UTC) + timedelta(seconds=expires_in), + **extra_claims, + } + return jwt.encode(payload, secret, algorithm="HS256") + + +@pytest.fixture +def channel_user(): + """Create a user who owns the client-bridge channel.""" + return UserFactory() + + +@pytest.fixture +def mailbox(channel_user): + """Create a test mailbox with access for the channel user.""" + mb = MailboxFactory() + mb.accesses.create(user=channel_user, role=models.MailboxRoleChoices.ADMIN) + return mb + + +@pytest.fixture +def client_bridge_channel(mailbox, channel_user): + """Create a client-bridge channel with an encrypted app-specific password.""" + return ChannelFactory( + mailbox=mailbox, + user=channel_user, + type=ChannelTypes.CLIENT_BRIDGE, + settings={"scopes": SCOPES_SENDER}, + encrypted_settings={"password": "test-app-password-123"}, + ) + + +@pytest.fixture +def api_client(): + """Provide an API client with the client-bridge service token.""" + client = APIClient() + client.credentials(HTTP_X_SERVICE_AUTH=f"Bearer {SERVICE_SECRET}") + return client + + +def _build_raw_email(mail_from, rcpt_to, subject="Test Subject"): + """Build a minimal RFC 5322 email for testing.""" + return ( + f"From: {mail_from}\r\n" + f"To: {rcpt_to}\r\n" + f"Subject: {subject}\r\n" + f"Message-ID: \r\n" + f"Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n" + f"MIME-Version: 1.0\r\n" + f"Content-Type: text/plain; charset=utf-8\r\n" + f"\r\n" + f"Test body\r\n" + ).encode() + + +@pytest.mark.django_db +class TestClientBridgeAuth: + """Test the client-bridge auth endpoint.""" + + @pytest.fixture(autouse=True) + def _enable_clientbridge(self, settings): + settings.FEATURE_CLIENTBRIDGE = True + settings.CLIENTBRIDGE_API_SECRET = SERVICE_SECRET + + def test_auth_success(self, api_client, client_bridge_channel, mailbox): + """Test successful authentication returns a valid JWT token.""" + mailbox_email = str(mailbox) + response = api_client.post( + "/api/v1.0/client-bridge/auth/", + { + "username": mailbox_email, + "password": "test-app-password-123", + }, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + assert "token" in response.data + + # All session data is in the JWT + payload = jwt.decode( + response.data["token"], SERVICE_SECRET, algorithms=["HS256"] + ) + assert payload["channel_id"] == str(client_bridge_channel.id) + assert payload["mailbox_id"] == str(mailbox.id) + assert payload["mailbox_email"] == mailbox_email + assert "exp" in payload + # Scopes are NOT in the JWT — read from the database + assert "scopes" not in payload + assert "role" not in payload + + def test_auth_wrong_password(self, api_client, client_bridge_channel, mailbox): # pylint: disable=unused-argument + """Test authentication fails with incorrect password.""" + response = api_client.post( + "/api/v1.0/client-bridge/auth/", + { + "username": str(mailbox), + "password": "wrong-password", + }, + format="json", + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.data["detail"] == "Invalid credentials." + + def test_auth_nonexistent_mailbox(self, api_client): + """Test authentication fails with nonexistent email address.""" + response = api_client.post( + "/api/v1.0/client-bridge/auth/", + { + "username": "nobody@nonexistent.example", + "password": "any-password", + }, + format="json", + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.data["detail"] == "Invalid credentials." + + def test_auth_missing_fields(self, api_client): + """Test authentication fails when required fields are missing.""" + response = api_client.post( + "/api/v1.0/client-bridge/auth/", + {}, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_auth_missing_password(self, api_client, client_bridge_channel, mailbox): # pylint: disable=unused-argument + """Test authentication fails when password is missing.""" + response = api_client.post( + "/api/v1.0/client-bridge/auth/", + {"username": str(mailbox)}, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_auth_invalid_username_format(self, api_client): + """Test authentication fails with non-email username.""" + response = api_client.post( + "/api/v1.0/client-bridge/auth/", + { + "username": "not-an-email", + "password": "any-password", + }, + format="json", + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_auth_no_client_bridge_channel(self, api_client, mailbox): + """Test authentication fails when mailbox has no client-bridge channels.""" + # Mailbox exists but has no client-bridge channel + response = api_client.post( + "/api/v1.0/client-bridge/auth/", + { + "username": str(mailbox), + "password": "any-password", + }, + format="json", + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_auth_wrong_channel_type_ignored(self, api_client, mailbox): + """Test authentication ignores non-client-bridge channel types.""" + ChannelFactory( + mailbox=mailbox, + type="widget", + encrypted_settings={"password": "test-password"}, + ) + + response = api_client.post( + "/api/v1.0/client-bridge/auth/", + { + "username": str(mailbox), + "password": "test-password", + }, + format="json", + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_auth_empty_password_in_settings(self, api_client, mailbox): + """Test authentication fails when channel has no password set.""" + ChannelFactory( + mailbox=mailbox, + type=ChannelTypes.CLIENT_BRIDGE, + encrypted_settings={}, + ) + + response = api_client.post( + "/api/v1.0/client-bridge/auth/", + { + "username": str(mailbox), + "password": "any-password", + }, + format="json", + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_auth_rejects_missing_service_token(self, client_bridge_channel, mailbox): # pylint: disable=unused-argument + """Test that requests without a service token are rejected.""" + client = APIClient() + response = client.post( + "/api/v1.0/client-bridge/auth/", + { + "username": str(mailbox), + "password": "test-app-password-123", + }, + format="json", + ) + + assert response.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) + + def test_auth_rejects_invalid_service_token(self, client_bridge_channel, mailbox): # pylint: disable=unused-argument + """Test that requests with an invalid service token are rejected.""" + client = APIClient() + client.credentials(HTTP_X_SERVICE_AUTH="wrong-secret") + response = client.post( + "/api/v1.0/client-bridge/auth/", + { + "username": str(mailbox), + "password": "test-app-password-123", + }, + format="json", + ) + + assert response.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) + + +@pytest.mark.django_db +class TestClientBridgeSubmit: + """Test submitting messages via client-bridge JWT on the unified /submit/ endpoint. + + The SubmitRawEmailView accepts both API-key and client-bridge JWT auth. + These tests exercise the client-bridge path (X-Channel-Token). + """ + + @pytest.fixture(autouse=True) + def _enable_clientbridge(self, settings): + settings.FEATURE_CLIENTBRIDGE = True + settings.CLIENTBRIDGE_API_SECRET = SERVICE_SECRET + + @patch("core.api.viewsets.submit.send_message_task") + def test_submit_success(self, mock_send_task, client_bridge_channel, mailbox): + """Test successful message submission creates message and dispatches delivery.""" + mailbox_email = str(mailbox) + rcpt_to = "recipient@example.com" + raw_email = _build_raw_email(mailbox_email, rcpt_to, subject="Hello") + token = _make_jwt(client_bridge_channel, mailbox) + + client = APIClient() + response = client.post( + "/api/v1.0/submit/", + raw_email, + content_type="message/rfc822", + HTTP_X_CHANNEL_TOKEN=token, + HTTP_X_MAIL_FROM=mailbox_email, + HTTP_X_RCPT_TO=rcpt_to, + ) + + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.data["status"] == "accepted" + + # Verify message was actually created in the database + message = models.Message.objects.get(id=response.data["message_id"]) + assert message.subject == "Hello" + assert message.is_sender is True + assert message.is_draft is False + assert message.sender.email == mailbox_email + assert message.channel == client_bridge_channel + assert message.blob is not None + + # Verify thread was created + assert message.thread is not None + assert models.ThreadAccess.objects.filter( + thread=message.thread, mailbox=mailbox + ).exists() + + # Verify recipient was created + assert message.recipients.filter(contact__email=rcpt_to).exists() + + # Verify async delivery was dispatched + mock_send_task.delay.assert_called_once_with(str(message.id)) + + @patch("core.api.viewsets.submit.send_message_task") + def test_submit_sender_mismatch( + self, mock_send_task, client_bridge_channel, mailbox + ): + """Test submission fails when From header doesn't match the mailbox.""" + raw_email = _build_raw_email("wrong@example.com", "recipient@example.com") + token = _make_jwt(client_bridge_channel, mailbox) + + client = APIClient() + response = client.post( + "/api/v1.0/submit/", + raw_email, + content_type="message/rfc822", + HTTP_X_CHANNEL_TOKEN=token, + HTTP_X_MAIL_FROM="wrong@example.com", + HTTP_X_RCPT_TO="recipient@example.com", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_send_task.delay.assert_not_called() + + def test_submit_missing_headers(self, client_bridge_channel, mailbox): + """Test submission fails when required headers are missing.""" + token = _make_jwt(client_bridge_channel, mailbox) + + client = APIClient() + response = client.post( + "/api/v1.0/submit/", + b"raw email", + content_type="message/rfc822", + HTTP_X_CHANNEL_TOKEN=token, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_submit_no_token(self, client_bridge_channel, mailbox): # pylint: disable=unused-argument + """Test submission fails without a JWT token.""" + mailbox_email = str(mailbox) + raw_email = _build_raw_email(mailbox_email, "recipient@example.com") + + client = APIClient() + response = client.post( + "/api/v1.0/submit/", + raw_email, + content_type="message/rfc822", + HTTP_X_MAIL_FROM=mailbox_email, + HTTP_X_RCPT_TO="recipient@example.com", + ) + + assert response.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) + + def test_submit_expired_token(self, client_bridge_channel, mailbox): + """Test submission fails with an expired JWT token.""" + token = _make_jwt(client_bridge_channel, mailbox, expires_in=-10) + + client = APIClient() + response = client.post( + "/api/v1.0/submit/", + b"raw email", + content_type="message/rfc822", + HTTP_X_CHANNEL_TOKEN=token, + HTTP_X_MAIL_FROM=str(mailbox), + HTTP_X_RCPT_TO="recipient@example.com", + ) + + assert response.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) + + def test_submit_tampered_token(self, client_bridge_channel, mailbox): + """Test submission fails with a JWT signed with wrong secret.""" + token = _make_jwt( + client_bridge_channel, + mailbox, + secret="wrong-secret-that-is-at-least-32-bytes-long", + ) + + client = APIClient() + response = client.post( + "/api/v1.0/submit/", + b"raw email", + content_type="message/rfc822", + HTTP_X_CHANNEL_TOKEN=token, + HTTP_X_MAIL_FROM=str(mailbox), + HTTP_X_RCPT_TO="recipient@example.com", + ) + + assert response.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) + + def test_submit_nonexistent_channel_in_token(self, mailbox): + """Test submission fails when JWT references a nonexistent channel.""" + # Create a fake channel object with a random UUID + fake_channel = type("FakeChannel", (), {"id": uuid.uuid4()})() + token = _make_jwt(fake_channel, mailbox) + + client = APIClient() + response = client.post( + "/api/v1.0/submit/", + b"raw email", + content_type="message/rfc822", + HTTP_X_CHANNEL_TOKEN=token, + HTTP_X_MAIL_FROM=str(mailbox), + HTTP_X_RCPT_TO="recipient@example.com", + ) + + assert response.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) + + @patch("core.api.viewsets.submit.send_message_task") + def test_submit_unparseable_email( + self, mock_send_task, client_bridge_channel, mailbox + ): + """Test submission fails when the email body cannot be parsed or has no valid sender.""" + token = _make_jwt(client_bridge_channel, mailbox) + + client = APIClient() + response = client.post( + "/api/v1.0/submit/", + b"", + content_type="message/rfc822", + HTTP_X_CHANNEL_TOKEN=token, + HTTP_X_MAIL_FROM=str(mailbox), + HTTP_X_RCPT_TO="recipient@example.com", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + mock_send_task.delay.assert_not_called() + + +@pytest.mark.django_db +class TestEncryptedSettings: + """Test the encrypted_settings field on the Channel model.""" + + def test_encrypted_settings_default(self, mailbox): + """Test that encrypted_settings defaults to empty dict.""" + channel = ChannelFactory(mailbox=mailbox, type="widget") + channel.refresh_from_db() + assert channel.encrypted_settings == {} + + def test_encrypted_settings_stores_password(self, mailbox): + """Test that encrypted_settings stores and retrieves password correctly.""" + channel = ChannelFactory( + mailbox=mailbox, + type=ChannelTypes.CLIENT_BRIDGE, + encrypted_settings={"password": "secret-password"}, + ) + channel.refresh_from_db() + assert channel.encrypted_settings["password"] == "secret-password" + + def test_encrypted_settings_roundtrip(self, mailbox): + """Test that encrypted_settings survives save/load roundtrip.""" + channel = ChannelFactory( + mailbox=mailbox, + type=ChannelTypes.CLIENT_BRIDGE, + encrypted_settings={"password": "test123", "extra": "data"}, + ) + channel.refresh_from_db() + assert channel.encrypted_settings == {"password": "test123", "extra": "data"} + + def test_encrypted_settings_not_stored_plaintext(self, mailbox): + """Test that encrypted_settings values are not stored as plaintext in the database.""" + from django.db import connection # pylint: disable=import-outside-toplevel + + password = "super-secret-password-12345" + channel = ChannelFactory( + mailbox=mailbox, + type=ChannelTypes.CLIENT_BRIDGE, + encrypted_settings={"password": password}, + ) + + with connection.cursor() as cursor: + cursor.execute( + "SELECT encrypted_settings FROM messages_channel WHERE id = %s", + [str(channel.id)], + ) + raw_value = cursor.fetchone()[0] + + assert password not in raw_value + + +@pytest.mark.django_db +class TestChannelSerializerPasswordExtraction: + """Test that the ChannelSerializer extracts password to encrypted_settings.""" + + @override_settings( + FEATURE_MAILBOX_ADMIN_CHANNELS=["client-bridge"], FEATURE_CLIENTBRIDGE=True + ) + def test_create_client_bridge_auto_generates_password(self, mailbox): + """Creating a client-bridge channel should auto-generate a password and return it once.""" + user = UserFactory() + mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.ADMIN) + client = APIClient() + client.force_authenticate(user=user) + + response = client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/channels/", + { + "name": "My Bridge", + "type": "client-bridge", + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + # Password should be returned in response + assert "password" in response.data + generated_password = response.data["password"] + assert len(generated_password) == 16 + + channel = models.Channel.objects.get(id=response.data["id"]) + assert channel.encrypted_settings["password"] == generated_password + # Password should NOT be in plain settings + assert "password" not in (channel.settings or {}) + + @override_settings( + FEATURE_MAILBOX_ADMIN_CHANNELS=["client-bridge"], FEATURE_CLIENTBRIDGE=True + ) + def test_rotate_password(self, mailbox): + """Rotating a client-bridge channel password should generate a new one.""" + user = UserFactory() + mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.ADMIN) + client = APIClient() + client.force_authenticate(user=user) + + channel = ChannelFactory( + mailbox=mailbox, + type=ChannelTypes.CLIENT_BRIDGE, + encrypted_settings={"password": "old-password"}, + ) + + response = client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/channels/{channel.id}/rotate-password/", + ) + + assert response.status_code == status.HTTP_200_OK + assert "password" in response.data + new_password = response.data["password"] + assert new_password != "old-password" + assert len(new_password) == 16 + + channel.refresh_from_db() + assert channel.encrypted_settings["password"] == new_password + + @override_settings( + FEATURE_MAILBOX_ADMIN_CHANNELS=["client-bridge"], FEATURE_CLIENTBRIDGE=True + ) + def test_rotate_password_rejects_non_client_bridge(self, mailbox): + """Rotating password should fail for non-client-bridge channels.""" + user = UserFactory() + mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.ADMIN) + client = APIClient() + client.force_authenticate(user=user) + + channel = ChannelFactory(mailbox=mailbox, type="widget") + + response = client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/channels/{channel.id}/rotate-password/", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=[], FEATURE_CLIENTBRIDGE=True) + def test_client_bridge_type_rejected_when_not_in_admin_channels(self, mailbox): + """Creating a client-bridge channel should fail when not in FEATURE_MAILBOX_ADMIN_CHANNELS.""" + user = UserFactory() + mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.ADMIN) + client = APIClient() + client.force_authenticate(user=user) + + response = client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/channels/", + { + "name": "My Bridge", + "type": "client-bridge", + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @override_settings( + FEATURE_MAILBOX_ADMIN_CHANNELS=["client-bridge"], FEATURE_CLIENTBRIDGE=False + ) + def test_client_bridge_type_rejected_when_backend_feature_disabled(self, mailbox): + """Creating a client-bridge channel should fail when FEATURE_CLIENTBRIDGE is False.""" + user = UserFactory() + mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.ADMIN) + client = APIClient() + client.force_authenticate(user=user) + + response = client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/channels/", + { + "name": "My Bridge", + "type": "client-bridge", + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @override_settings( + FEATURE_MAILBOX_ADMIN_CHANNELS=["client-bridge"], FEATURE_CLIENTBRIDGE=True + ) + def test_create_client_bridge_saves_user(self, mailbox, channel_user): + """Creating a client-bridge channel via API should save request.user on the channel.""" + client = APIClient() + client.force_authenticate(user=channel_user) + + response = client.post( + f"/api/v1.0/mailboxes/{mailbox.id}/channels/", + { + "name": "My Bridge", + "type": "client-bridge", + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + channel = models.Channel.objects.get(id=response.data["id"]) + assert channel.user == channel_user + + +@pytest.mark.django_db +class TestChannelJwtAuthentication: + """Test ChannelJwtAuthentication on the /submit/ endpoint (the only + view that whitelists it). The auth class is NOT in + DEFAULT_AUTHENTICATION_CLASSES — views must opt in explicitly.""" + + @pytest.fixture(autouse=True) + def _settings(self, settings): + settings.CLIENTBRIDGE_API_SECRET = SERVICE_SECRET + + def test_expired_token_rejected(self, client_bridge_channel, mailbox): + """Expired JWT should fail with 401.""" + token = _make_jwt(client_bridge_channel, mailbox, expires_in=-10) + client = APIClient() + response = client.post( + "/api/v1.0/submit/", + b"raw email", + content_type="message/rfc822", + HTTP_X_CHANNEL_TOKEN=token, + HTTP_X_RCPT_TO="recipient@example.com", + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_tampered_token_rejected(self, client_bridge_channel, mailbox): + """JWT signed with wrong secret should not authenticate.""" + token = _make_jwt( + client_bridge_channel, + mailbox, + secret="wrong-secret-that-is-at-least-32-bytes-long", + ) + client = APIClient() + response = client.post( + "/api/v1.0/submit/", + b"raw email", + content_type="message/rfc822", + HTTP_X_CHANNEL_TOKEN=token, + HTTP_X_RCPT_TO="recipient@example.com", + ) + assert response.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) + + def test_nonexistent_channel_rejected(self, mailbox): + """JWT referencing a nonexistent channel should fail.""" + fake_channel = type("FakeChannel", (), {"id": uuid.uuid4()})() + token = _make_jwt(fake_channel, mailbox) + client = APIClient() + response = client.post( + "/api/v1.0/submit/", + b"raw email", + content_type="message/rfc822", + HTTP_X_CHANNEL_TOKEN=token, + HTTP_X_RCPT_TO="recipient@example.com", + ) + assert response.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) + + def test_token_without_exp_rejected(self, client_bridge_channel, mailbox): + """A JWT missing the 'exp' claim should be rejected.""" + payload = { + "channel_id": str(client_bridge_channel.id), + "mailbox_id": str(mailbox.id), + } + token = jwt.encode(payload, SERVICE_SECRET, algorithm="HS256") + client = APIClient() + response = client.post( + "/api/v1.0/submit/", + b"raw email", + content_type="message/rfc822", + HTTP_X_CHANNEL_TOKEN=token, + HTTP_X_RCPT_TO="recipient@example.com", + ) + assert response.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) + + def test_jwt_not_accepted_on_generic_endpoints(self, client_bridge_channel, mailbox): + """JWT should NOT authenticate on endpoints that don't whitelist it.""" + token = _make_jwt(client_bridge_channel, mailbox) + client = APIClient() + response = client.get( + "/api/v1.0/threads/", + {"mailbox_id": str(mailbox.id)}, + HTTP_X_CHANNEL_TOKEN=token, + ) + assert response.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) + + +@pytest.mark.django_db +class TestChannelJwtScopeEnforcement: + """Test that channel scopes are enforced on /submit/ via the + CanSubmitMessage permission class.""" + + @pytest.fixture(autouse=True) + def _settings(self, settings): + settings.CLIENTBRIDGE_API_SECRET = SERVICE_SECRET + + @pytest.fixture + def _make_channel(self, mailbox, channel_user): + """Helper to create a client-bridge channel with given scopes.""" + + def _factory(scopes): + return ChannelFactory( + mailbox=mailbox, + user=channel_user, + type=ChannelTypes.CLIENT_BRIDGE, + encrypted_settings={"password": "test-pass"}, + settings={"scopes": list(scopes)}, + ) + + return _factory + + def _jwt_client(self, channel, mailbox): + """Create an APIClient with a JWT for the given channel.""" + token = _make_jwt(channel, mailbox) + client = APIClient() + client.credentials(HTTP_X_CHANNEL_TOKEN=token) + return client + + # ── /api/v1.0/submit/ scope enforcement ──────────────────────────── + + def test_submit_blocked_reader(self, _make_channel, mailbox): + """Reader channels lack messages:send — blocked by CanSubmitMessage.""" + channel = _make_channel(SCOPES_READER) + client = self._jwt_client(channel, mailbox) + mailbox_email = str(mailbox) + raw_email = _build_raw_email(mailbox_email, "recipient@example.com") + + response = client.post( + "/api/v1.0/submit/", + raw_email, + content_type="message/rfc822", + HTTP_X_RCPT_TO="recipient@example.com", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_submit_blocked_editor(self, _make_channel, mailbox): + """Editor channels lack messages:send — blocked by CanSubmitMessage.""" + channel = _make_channel(SCOPES_EDITOR) + client = self._jwt_client(channel, mailbox) + mailbox_email = str(mailbox) + raw_email = _build_raw_email(mailbox_email, "recipient@example.com") + + response = client.post( + "/api/v1.0/submit/", + raw_email, + content_type="message/rfc822", + HTTP_X_RCPT_TO="recipient@example.com", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("core.api.viewsets.submit.send_message_task") + @pytest.mark.parametrize("scopes", [SCOPES_SENDER, SCOPES_SEND_ONLY]) + def test_submit_allows_sender_scopes( + self, + mock_send_task, # pylint: disable=unused-argument + _make_channel, + mailbox, + scopes, # pylint: disable=unused-argument + ): + """Channels with messages:send should be allowed to submit.""" + channel = _make_channel(scopes) + client = self._jwt_client(channel, mailbox) + mailbox_email = str(mailbox) + raw_email = _build_raw_email(mailbox_email, "recipient@example.com") + + response = client.post( + "/api/v1.0/submit/", + raw_email, + content_type="message/rfc822", + HTTP_X_RCPT_TO="recipient@example.com", + ) + assert response.status_code == status.HTTP_202_ACCEPTED + + @patch("core.api.viewsets.submit.send_message_task") + def test_submit_sets_sender_user( + self, + mock_send_task, # pylint: disable=unused-argument + _make_channel, + mailbox, + ): + """Submit should set message.sender_user to the channel's user.""" + channel = _make_channel(SCOPES_SENDER) + client = self._jwt_client(channel, mailbox) + mailbox_email = str(mailbox) + raw_email = _build_raw_email(mailbox_email, "recipient@example.com") + + response = client.post( + "/api/v1.0/submit/", + raw_email, + content_type="message/rfc822", + HTTP_X_RCPT_TO="recipient@example.com", + ) + assert response.status_code == status.HTTP_202_ACCEPTED + message = models.Message.objects.get(id=response.data["message_id"]) + assert message.sender_user == channel.user + + def test_no_scopes_blocked(self, mailbox, channel_user): + """A channel with no scopes should be blocked on /submit/.""" + channel = ChannelFactory( + mailbox=mailbox, + user=channel_user, + type=ChannelTypes.CLIENT_BRIDGE, + encrypted_settings={"password": "test-pass"}, + settings={}, + ) + client = self._jwt_client(channel, mailbox) + mailbox_email = str(mailbox) + raw_email = _build_raw_email(mailbox_email, "recipient@example.com") + + response = client.post( + "/api/v1.0/submit/", + raw_email, + content_type="message/rfc822", + HTTP_X_RCPT_TO="recipient@example.com", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + # ── Auth endpoint JWT is minimal ────────────────────────────────── + + @pytest.mark.parametrize("scopes", [SCOPES_READER, SCOPES_EDITOR, SCOPES_SENDER, SCOPES_SEND_ONLY]) + def test_auth_jwt_has_no_scopes( + self, api_client, _make_channel, mailbox, scopes + ): + """Auth endpoint JWT should only contain channel_id, mailbox_id, mailbox_email, exp.""" + _make_channel(scopes) + response = api_client.post( + "/api/v1.0/client-bridge/auth/", + {"username": str(mailbox), "password": "test-pass"}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + payload = jwt.decode( + response.data["token"], SERVICE_SECRET, algorithms=["HS256"] + ) + assert set(payload.keys()) == {"channel_id", "mailbox_id", "mailbox_email", "exp"} diff --git a/src/backend/core/tests/api/test_config.py b/src/backend/core/tests/api/test_config.py index c42333fa0..15b1c84a5 100644 --- a/src/backend/core/tests/api/test_config.py +++ b/src/backend/core/tests/api/test_config.py @@ -36,6 +36,7 @@ IMAGE_PROXY_ENABLED=False, MESSAGES_MANUAL_RETRY_MAX_AGE=86400, # 1 day in seconds FRONTEND_SILENT_LOGIN_ENABLED=True, + FEATURE_CLIENTBRIDGE=False, ) @pytest.mark.parametrize("is_authenticated", [False, True]) def test_api_config(is_authenticated): diff --git a/src/backend/core/tests/api/test_mailbox_usage_metrics.py b/src/backend/core/tests/api/test_mailbox_usage_metrics.py index ec3fed401..5cae29d8b 100644 --- a/src/backend/core/tests/api/test_mailbox_usage_metrics.py +++ b/src/backend/core/tests/api/test_mailbox_usage_metrics.py @@ -6,7 +6,7 @@ import pytest from core.enums import ( - ChannelApiKeyScope, + ChannelScope, MessageTemplateTypeChoices, ) from core.factories import ( @@ -33,7 +33,7 @@ def url(): def correctly_configured_header(db): """Returns the authentication headers via a global api_key Channel.""" channel, plaintext = make_api_key_channel( - scopes=(ChannelApiKeyScope.METRICS_READ.value,), + scopes=(ChannelScope.METRICS_READ.value,), name="metrics-test", ) return { diff --git a/src/backend/core/tests/api/test_maildomain_users_metrics.py b/src/backend/core/tests/api/test_maildomain_users_metrics.py index 2e83d533a..d78d0975a 100644 --- a/src/backend/core/tests/api/test_maildomain_users_metrics.py +++ b/src/backend/core/tests/api/test_maildomain_users_metrics.py @@ -9,7 +9,7 @@ import pytest from core.enums import ( - ChannelApiKeyScope, + ChannelScope, MessageTemplateTypeChoices, ) from core.factories import ( @@ -32,7 +32,7 @@ def _make_metrics_api_key(): """Create a global-scope api_key channel with metrics:read.""" return make_api_key_channel( - scopes=(ChannelApiKeyScope.METRICS_READ.value,), + scopes=(ChannelScope.METRICS_READ.value,), name="metrics-test", ) diff --git a/src/backend/core/tests/api/test_provisioning_mailbox.py b/src/backend/core/tests/api/test_provisioning_mailbox.py index d0d311669..4248bf74d 100644 --- a/src/backend/core/tests/api/test_provisioning_mailbox.py +++ b/src/backend/core/tests/api/test_provisioning_mailbox.py @@ -9,7 +9,7 @@ from rest_framework.test import APIClient from core.enums import ( - ChannelApiKeyScope, + ChannelScope, ChannelScopeLevel, MailboxRoleChoices, ) @@ -24,7 +24,7 @@ MAILBOX_URL = reverse("provisioning-mailboxes") -def _make_api_key_channel(scopes=(ChannelApiKeyScope.MAILBOXES_READ.value,), **kwargs): +def _make_api_key_channel(scopes=(ChannelScope.MAILBOXES_READ.value,), **kwargs): """Wrapper around the shared factory pre-loaded with the provisioning-endpoint default scope (mailboxes:read). Callers can still override ``scopes`` and any other kwarg.""" @@ -58,7 +58,7 @@ def mailbox(domain): @pytest.mark.django_db class TestServiceAuthSecurity: - """Verify that the provisioning endpoint requires ChannelApiKeyScope.MAILBOXES_READ.""" + """Verify that the provisioning endpoint requires ChannelScope.MAILBOXES_READ.""" def test_user_email_no_auth_returns_401(self, client): response = client.get(MAILBOX_URL, {"user_email": "a@b.com"}) @@ -98,7 +98,7 @@ def test_user_email_malformed_channel_returns_401(self, client): def test_user_email_wrong_scope_returns_403(self, client): channel, plaintext = _make_api_key_channel( - scopes=(ChannelApiKeyScope.METRICS_READ.value,), + scopes=(ChannelScope.METRICS_READ.value,), ) response = client.get( MAILBOX_URL, diff --git a/src/backend/core/tests/api/test_provisioning_maildomains.py b/src/backend/core/tests/api/test_provisioning_maildomains.py index e3630bdf5..7bde201ee 100644 --- a/src/backend/core/tests/api/test_provisioning_maildomains.py +++ b/src/backend/core/tests/api/test_provisioning_maildomains.py @@ -7,13 +7,13 @@ import pytest -from core.enums import ChannelApiKeyScope, ChannelScopeLevel +from core.enums import ChannelScope, ChannelScopeLevel from core.factories import MailDomainFactory, make_api_key_channel from core.models import MailDomain def _make_api_key_channel( - scopes=(ChannelApiKeyScope.MAILDOMAINS_CREATE.value,), **kwargs + scopes=(ChannelScope.MAILDOMAINS_CREATE.value,), **kwargs ): """Wrapper around the shared factory pre-loaded with the maildomains-write scope used by every test in this module.""" @@ -29,7 +29,7 @@ def url(): @pytest.fixture def auth_header(): - """Global-scope api_key with ChannelApiKeyScope.MAILDOMAINS_CREATE.""" + """Global-scope api_key with ChannelScope.MAILDOMAINS_CREATE.""" channel, plaintext = _make_api_key_channel() return { "HTTP_X_CHANNEL_ID": str(channel.id), diff --git a/src/backend/core/tests/api/test_submit.py b/src/backend/core/tests/api/test_submit.py index a5a0eb2bb..859e9173a 100644 --- a/src/backend/core/tests/api/test_submit.py +++ b/src/backend/core/tests/api/test_submit.py @@ -7,7 +7,7 @@ import pytest from dkim import verify as dkim_verify -from core.enums import ChannelApiKeyScope, ChannelScopeLevel +from core.enums import ChannelScope, ChannelScopeLevel from core.factories import MailboxFactory, MailDomainFactory, make_api_key_channel from core.mda.signing import generate_dkim_key @@ -33,7 +33,7 @@ def _make_api_key_channel(**kwargs): """Thin wrapper around the shared factory pre-loaded with the submit-endpoint default scope (messages:send).""" - kwargs.setdefault("scopes", (ChannelApiKeyScope.MESSAGES_SEND.value,)) + kwargs.setdefault("scopes", (ChannelScope.MESSAGES_SEND.value,)) kwargs.setdefault("name", "test-key") return make_api_key_channel(**kwargs) @@ -106,7 +106,7 @@ def test_unknown_channel_returns_401(self, client, mailbox): def test_missing_scope_returns_403(self, client, mailbox): channel, plaintext = _make_api_key_channel( - scopes=(ChannelApiKeyScope.MAILBOXES_READ.value,), + scopes=(ChannelScope.MAILBOXES_READ.value,), ) response = client.post( SUBMIT_URL, diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 71b02d3c2..c02504754 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -7,6 +7,7 @@ from core.api.viewsets.blob import BlobViewSet from core.api.viewsets.channel import ChannelViewSet, UserChannelViewSet +from core.api.viewsets.client_bridge import ClientBridgeAuthView from core.api.viewsets.config import ConfigView from core.api.viewsets.contacts import ContactViewSet from core.api.viewsets.draft import DraftMessageView @@ -287,6 +288,15 @@ ), ] +if settings.FEATURE_CLIENTBRIDGE: + urlpatterns += [ + path( + f"api/{settings.API_VERSION}/client-bridge/auth/", + ClientBridgeAuthView.as_view(), + name="client-bridge-auth", + ), + ] + if settings.DRIVE_CONFIG.get("base_url"): urlpatterns += [ path( diff --git a/src/backend/e2e/management/commands/e2e_clientbridge.py b/src/backend/e2e/management/commands/e2e_clientbridge.py new file mode 100644 index 000000000..d5013833c --- /dev/null +++ b/src/backend/e2e/management/commands/e2e_clientbridge.py @@ -0,0 +1,186 @@ +""" +Django management command to bootstrap client-bridge E2E test data. + +Separated from e2e_demo so that db:reset (used by non-client-bridge tests) +doesn't pay the cost of creating channels and EML blobs on every call. +""" + +from email.mime.text import MIMEText +from email.utils import format_datetime + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + +from core import models +from core.enums import ( + CLIENT_BRIDGE_ROLE_SCOPES, + ChannelTypes, + MailboxRoleChoices, + ThreadAccessRoleChoices, +) + +BROWSERS = ["chromium", "firefox", "webkit"] +DOMAIN_NAME = "example.local" +SHARED_MAILBOX_LOCAL_PART = "shared.e2e" +CLIENTBRIDGE_APP_PASSWORD = "e2e-client-bridge-password" # noqa: S105 + + +class Command(BaseCommand): + """Create client-bridge channels and IMAP test data for E2E testing.""" + + help = "Create client-bridge E2E data (channels and IMAP test messages)" + + @transaction.atomic + def handle(self, *args, **options): + """Execute the command.""" + self.stdout.write( + self.style.WARNING("\n\n| Creating client-bridge E2E data\n") + ) + + domain = models.MailDomain.objects.get(name=DOMAIN_NAME) + + # Create channels for all regular user mailboxes + for browser in BROWSERS: + try: + mailbox = models.Mailbox.objects.get( + local_part=f"user.e2e.{browser}", domain=domain + ) + self._create_clientbridge_channel(mailbox) + except models.Mailbox.DoesNotExist: + self.stdout.write( + self.style.WARNING( + f" Mailbox user.e2e.{browser} not found, skipping" + ) + ) + + # Create channel for shared mailbox + try: + shared_mailbox = models.Mailbox.objects.get( + local_part=SHARED_MAILBOX_LOCAL_PART, domain=domain + ) + self._create_clientbridge_channel(shared_mailbox) + except models.Mailbox.DoesNotExist: + self.stdout.write( + self.style.WARNING(" Shared mailbox not found, skipping") + ) + + # Create IMAP test messages on the first regular user's mailbox (chromium) + first_mailbox = models.Mailbox.objects.get( + local_part="user.e2e.chromium", domain=domain + ) + self._create_imap_test_messages(first_mailbox, domain) + + def _create_clientbridge_channel(self, mailbox): + """Create a client-bridge channel with a known password for e2e testing.""" + access = models.MailboxAccess.objects.filter( + mailbox=mailbox, role=MailboxRoleChoices.ADMIN + ).first() + if not access: + self.stdout.write( + self.style.WARNING( + f" No admin user found for {mailbox}, skipping channel" + ) + ) + return + + _channel, created = models.Channel.objects.get_or_create( + mailbox=mailbox, + type=ChannelTypes.CLIENT_BRIDGE, + defaults={ + "name": f"E2E client-bridge ({mailbox})", + "user": access.user, + "settings": {"scopes": list(CLIENT_BRIDGE_ROLE_SCOPES["sender"])}, + "encrypted_settings": {"password": CLIENTBRIDGE_APP_PASSWORD}, + }, + ) + if created: + self.stdout.write( + self.style.SUCCESS(f" Created client-bridge channel for {mailbox}") + ) + else: + self.stdout.write( + self.style.SUCCESS( + f" Client-bridge channel already exists for {mailbox}" + ) + ) + + @staticmethod + def _make_eml(subject, sender_email, recipient_email, body, sent_at): + """Build a minimal RFC 5322 message and return raw bytes.""" + msg = MIMEText(body, "plain") + msg["Subject"] = subject + msg["From"] = sender_email + msg["To"] = recipient_email + msg["Date"] = format_datetime(sent_at) + return msg.as_bytes() + + def _create_imap_test_messages(self, mailbox, domain): + """Create messages for IMAP read/unread e2e testing.""" + sender_email = f"imap-sender@{domain.name}" + recipient_email = str(mailbox) + sender_contact, _ = models.Contact.objects.get_or_create( + email=sender_email, + mailbox=mailbox, + defaults={"name": "IMAP Test Sender"}, + ) + + # Thread 1: an unread message + now = timezone.now() + thread1 = models.Thread.objects.create(subject="IMAP unread test") + models.ThreadAccess.objects.create( + thread=thread1, + mailbox=mailbox, + role=ThreadAccessRoleChoices.EDITOR, + read_at=None, + ) + eml1 = self._make_eml( + "IMAP unread test", + sender_email, + recipient_email, + "This message should appear as unread in IMAP.", + now, + ) + blob1 = mailbox.create_blob(content=eml1, content_type="message/rfc822") + models.Message.objects.create( + thread=thread1, + sender=sender_contact, + subject="IMAP unread test", + is_sender=False, + is_draft=False, + sent_at=now, + blob=blob1, + ) + thread1.update_stats() + + # Thread 2: a read message + sent_at2 = now - timezone.timedelta(minutes=5) + thread2 = models.Thread.objects.create(subject="IMAP read test") + models.ThreadAccess.objects.create( + thread=thread2, + mailbox=mailbox, + role=ThreadAccessRoleChoices.EDITOR, + read_at=now + timezone.timedelta(minutes=1), + ) + eml2 = self._make_eml( + "IMAP read test", + sender_email, + recipient_email, + "This message should appear as read in IMAP.", + sent_at2, + ) + blob2 = mailbox.create_blob(content=eml2, content_type="message/rfc822") + models.Message.objects.create( + thread=thread2, + sender=sender_contact, + subject="IMAP read test", + is_sender=False, + is_draft=False, + sent_at=sent_at2, + blob=blob2, + ) + thread2.update_stats() + + self.stdout.write( + self.style.SUCCESS(f" Created IMAP test messages for {mailbox}") + ) diff --git a/src/backend/e2e/management/commands/e2e_demo.py b/src/backend/e2e/management/commands/e2e_demo.py index 04dc4d472..146fc5a5e 100644 --- a/src/backend/e2e/management/commands/e2e_demo.py +++ b/src/backend/e2e/management/commands/e2e_demo.py @@ -3,15 +3,19 @@ This command creates demo users, mailboxes, shared mailboxes, and outbox test data for E2E testing across different browsers (chromium, firefox, webkit). + +Client-bridge specific data (channels, IMAP test messages) is handled by the +separate e2e_clientbridge command to avoid recreating it on every db:reset. """ -from django.conf import settings from django.core.management.base import BaseCommand from django.db import transaction from django.utils import timezone from core import models from core.enums import ( + CLIENT_BRIDGE_ROLE_SCOPES, + ChannelTypes, MailboxRoleChoices, MailDomainAccessRoleChoices, MessageDeliveryStatusChoices, @@ -24,6 +28,7 @@ DOMAIN_NAME = "example.local" SHARED_MAILBOX_LOCAL_PART = "shared.e2e" IMPORT_MAILBOX_LOCAL_PART = "import.e2e" +CLIENTBRIDGE_APP_PASSWORD = "e2e-client-bridge-password" # noqa: S105 class Command(BaseCommand): @@ -138,14 +143,26 @@ def handle(self, *args, **options): self._create_outbox_test_data(domain, browser) # Step 7: Create inbox test data for each browser - self.stdout.write("\n-- 6/7 đŸ“„ Creating inbox test data") + self.stdout.write("\n-- 6/8 đŸ“„ Creating inbox test data") for browser in BROWSERS: self._create_inbox_test_data(domain, browser) # Step 8: Create shared mailbox thread data for IM testing - self.stdout.write("\n-- 7/7 💬 Creating shared mailbox thread for IM testing") + self.stdout.write("\n-- 7/8 💬 Creating shared mailbox thread for IM testing") self._create_shared_mailbox_thread_data(shared_mailbox, regular_users) + # Step 9: Create client-bridge channels and IMAP test data + if settings.FEATURE_CLIENTBRIDGE: + self.stdout.write( + "\n-- 8/8 📧 Creating client-bridge channels and IMAP test data" + ) + for _user, mailbox in regular_users: + self._create_clientbridge_channel(mailbox) + self._create_clientbridge_channel(shared_mailbox) + # Create IMAP test messages on the first regular user's mailbox + _first_user, first_mailbox = regular_users[0] + self._create_imap_test_messages(first_mailbox, domain) + def _create_user_with_mailbox( self, email, domain, is_domain_admin=False, is_superuser=False ): @@ -554,3 +571,123 @@ def _create_shared_mailbox_thread_data(self, shared_mailbox, regular_users): self.stdout.write( self.style.SUCCESS(f" ✓ Shared mailbox IM thread created: {subject}") ) + + def _create_clientbridge_channel(self, mailbox): + """Create a client-bridge channel with a known password for e2e testing.""" + # Use the first user with admin access to this mailbox + access = models.MailboxAccess.objects.filter( + mailbox=mailbox, role=MailboxRoleChoices.ADMIN + ).first() + if not access: + self.stdout.write( + self.style.WARNING( + f" No admin user found for {mailbox}, skipping channel" + ) + ) + return + + _channel, created = models.Channel.objects.get_or_create( + mailbox=mailbox, + type=ChannelTypes.CLIENT_BRIDGE, + defaults={ + "name": f"E2E client-bridge ({mailbox})", + "user": access.user, + "settings": {"scopes": list(CLIENT_BRIDGE_ROLE_SCOPES["sender"])}, + "encrypted_settings": {"password": CLIENTBRIDGE_APP_PASSWORD}, + }, + ) + if created: + self.stdout.write( + self.style.SUCCESS(f" Created client-bridge channel for {mailbox}") + ) + else: + self.stdout.write( + self.style.SUCCESS( + f" Client-bridge channel already exists for {mailbox}" + ) + ) + + @staticmethod + def _make_eml(subject, sender_email, recipient_email, body, sent_at): + """Build a minimal RFC 5322 message and return raw bytes.""" + msg = MIMEText(body, "plain") + msg["Subject"] = subject + msg["From"] = sender_email + msg["To"] = recipient_email + msg["Date"] = format_datetime(sent_at) + return msg.as_bytes() + + def _create_imap_test_messages(self, mailbox, domain): + """Create messages for IMAP read/unread e2e testing. + + Creates two threads with proper EML blobs so the client-bridge + can serve envelope data (subject, from, date) over IMAP. + """ + sender_email = f"imap-sender@{domain.name}" + recipient_email = str(mailbox) + sender_contact, _ = models.Contact.objects.get_or_create( + email=sender_email, + mailbox=mailbox, + defaults={"name": "IMAP Test Sender"}, + ) + + # Thread 1: an unread message + now = timezone.now() + thread1 = models.Thread.objects.create(subject="IMAP unread test") + models.ThreadAccess.objects.create( + thread=thread1, + mailbox=mailbox, + role=ThreadAccessRoleChoices.EDITOR, + read_at=None, # No read_at → all messages unread + ) + eml1 = self._make_eml( + "IMAP unread test", + sender_email, + recipient_email, + "This message should appear as unread in IMAP.", + now, + ) + blob1 = mailbox.create_blob(content=eml1, content_type="message/rfc822") + models.Message.objects.create( + thread=thread1, + sender=sender_contact, + subject="IMAP unread test", + is_sender=False, + is_draft=False, + sent_at=now, + blob=blob1, + ) + thread1.update_stats() + + # Thread 2: a read message + sent_at2 = now - timezone.timedelta(minutes=5) + thread2 = models.Thread.objects.create(subject="IMAP read test") + models.ThreadAccess.objects.create( + thread=thread2, + mailbox=mailbox, + role=ThreadAccessRoleChoices.EDITOR, + read_at=now + + timezone.timedelta(minutes=1), # read_at after message created_at → read + ) + eml2 = self._make_eml( + "IMAP read test", + sender_email, + recipient_email, + "This message should appear as read in IMAP.", + sent_at2, + ) + blob2 = mailbox.create_blob(content=eml2, content_type="message/rfc822") + models.Message.objects.create( + thread=thread2, + sender=sender_contact, + subject="IMAP read test", + is_sender=False, + is_draft=False, + sent_at=sent_at2, + blob=blob2, + ) + thread2.update_stats() + + self.stdout.write( + self.style.SUCCESS(f" Created IMAP test messages for {mailbox}") + ) diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index 94eee56c7..d337018e4 100755 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -14,6 +14,7 @@ import logging import os import tomllib +import warnings from socket import gethostbyname, gethostname import dj_database_url @@ -276,6 +277,27 @@ class Base(Configuration): }, }, } + # Client-bridge service authentication + CLIENTBRIDGE_API_SECRET = values.Value( + "", + environ_name="CLIENTBRIDGE_API_SECRET", + environ_prefix=None, + ) + # Session JWT lifetime in seconds (default: 1 hour). + # IMAP/SMTP clients must re-authenticate when the token expires. + CLIENTBRIDGE_SESSION_TIMEOUT = values.PositiveIntegerValue( + 3600, + environ_name="CLIENTBRIDGE_SESSION_TIMEOUT", + environ_prefix=None, + ) + # Client-bridge connection settings exposed to the frontend via /api/v1.0/config/. + # Set as a JSON string, e.g.: + # CLIENTBRIDGE_PUBLIC_CONFIG='{"imap_host":"imap.example.com", + # "imap_port":993,"smtp_host":"smtp.example.com","smtp_port":587}' + CLIENTBRIDGE_PUBLIC_CONFIG = values.DictValue( + {}, environ_name="CLIENTBRIDGE_PUBLIC_CONFIG", environ_prefix=None + ) + # MDA settings MDA_API_SECRET = values.Value( "my-shared-secret-mda", environ_name="MDA_API_SECRET", environ_prefix=None @@ -593,6 +615,11 @@ class Base(Configuration): environ_name="API_USERS_LIST_THROTTLE_RATE_BURST", environ_prefix=None, ), + "client_bridge_auth": values.Value( + default="5/minute", + environ_name="API_CLIENT_BRIDGE_AUTH_THROTTLE_RATE", + environ_prefix=None, + ), }, } @@ -873,6 +900,9 @@ class Base(Configuration): environ_name="FEATURE_MAILBOX_ADMIN_CHANNELS", environ_prefix=None, ) + FEATURE_CLIENTBRIDGE = values.BooleanValue( + default=False, environ_name="FEATURE_CLIENTBRIDGE", environ_prefix=None + ) FEATURE_MAILDOMAIN_CREATE = values.BooleanValue( default=True, environ_name="FEATURE_MAILDOMAIN_CREATE", environ_prefix=None ) @@ -1100,6 +1130,13 @@ def post_setup(cls): "OIDC_STORE_REFRESH_TOKEN is enabled." ) + if cls.FEATURE_CLIENTBRIDGE and not cls.CLIENTBRIDGE_API_SECRET: + warnings.warn( + "CLIENTBRIDGE_API_SECRET must be set when " + "FEATURE_CLIENTBRIDGE is enabled", + stacklevel=1, + ) + class Build(Base): """Settings used when the application is built. diff --git a/src/backend/pylint_custom.py b/src/backend/pylint_custom.py new file mode 100644 index 000000000..fca7073ee --- /dev/null +++ b/src/backend/pylint_custom.py @@ -0,0 +1,50 @@ +"""Custom pylint checkers for the Messages backend.""" + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + + +class NoGetAttrSettingsChecker(BaseChecker): + """Forbid ``getattr(settings, ...)`` — use ``settings.SETTING`` directly. + + Django settings defined with django-configurations are real class + attributes. Using ``getattr`` with a default silently hides missing + settings and bypasses the configuration layer. + """ + + name = "no-getattr-settings" + msgs = { + "C9001": ( + "Do not use getattr() on Django settings. " + "Access settings.%s directly instead.", + "getattr-on-settings", + "getattr(settings, ...) bypasses django-configurations and " + "silently falls back to a hardcoded default. Declare the " + "setting in the Configuration class and access it directly.", + ), + } + + def visit_call(self, node: nodes.Call) -> None: # pylint: disable=missing-function-docstring + # Match getattr(settings, "SOMETHING", ...) + if not isinstance(node.func, nodes.Name) or node.func.name != "getattr": + return + if len(node.args) < 2: + return + + obj = node.args[0] + attr = node.args[1] + + # Check if the first argument is `settings` or `django_settings` + if not isinstance(obj, nodes.Name): + return + if obj.name not in ("settings", "django_settings"): + return + + # Extract the setting name for the message + setting_name = attr.value if isinstance(attr, nodes.Const) else "..." + self.add_message("getattr-on-settings", node=node, args=(setting_name,)) + + +def register(linter: PyLinter) -> None: # pylint: disable=missing-function-docstring + linter.register_checker(NoGetAttrSettingsChecker(linter)) diff --git a/src/client-bridge/Dockerfile b/src/client-bridge/Dockerfile new file mode 100644 index 000000000..3045d91a5 --- /dev/null +++ b/src/client-bridge/Dockerfile @@ -0,0 +1,78 @@ +# https://hub.docker.com/_/python +FROM python:3.14.3-slim-trixie AS base + +# Bump this to force an update of the apt repositories +ENV MIN_UPDATE_DATE="2026-03-08" + +WORKDIR /app + +RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + procps \ + net-tools \ + && rm -rf /var/lib/apt/lists/* + +# ---- uv package manager ---- +FROM base AS uv + +COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /uvx /bin/ + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_PYTHON_DOWNLOADS=never +ENV UV_PROJECT_ENVIRONMENT=/venv + +# ---- Base image with dependencies installed ---- +FROM uv AS base-with-deps + +COPY pyproject.toml uv.lock ./ + +ENV PATH="/venv/bin:$PATH" + +# Install dependencies +RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-install-project --no-dev + +# ---- Base image with dependencies installed for development ---- +FROM base-with-deps AS base-with-deps-dev + +RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-install-project --all-extras + +ENV PYTHONPATH="/app" +ENV PYTHONIOENCODING=utf-8 +ENV PYTHONUNBUFFERED=1 + + +# ---- Base runtime image ---- +FROM base AS runtime-base + +COPY ./entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +RUN useradd --create-home appuser + +ENV PATH="/venv/bin:$PATH" +ENV VIRTUAL_ENV=/venv +ENV VIRTUAL_ENV_PROMPT=venv + +ENTRYPOINT [ "/usr/local/bin/entrypoint.sh" ] + +# ---- Base runtime image for development ---- +FROM runtime-base AS runtime-dev + +COPY --from=base-with-deps-dev /venv /venv + +ENV PYTHONPATH="/app" +ENV PYTHONIOENCODING=utf-8 +ENV PYTHONUNBUFFERED=1 + +# /app will be mounted as a volume in the development container + +# ---- Base runtime image for production ---- +FROM runtime-base AS runtime-prod + +COPY --from=base-with-deps /venv /venv + +COPY ./src /app/src + +USER appuser diff --git a/src/client-bridge/README.md b/src/client-bridge/README.md new file mode 100644 index 000000000..a8192d135 --- /dev/null +++ b/src/client-bridge/README.md @@ -0,0 +1,205 @@ +# Client Bridge (IMAP/SMTP) + +The client bridge is an **optional** component that exposes Messages mailboxes to email clients (Thunderbird, mobile phones, etc.) via the legacy IMAP and SMTP protocols. + +Messages is natively a modern, web-based messaging platform — closer in spirit to JMAP than to IMAP. The client bridge is a compatibility layer for users who prefer or need to access their mailbox from a traditional email client. Some Messages features (labels, real-time collaboration, rich threads) may not be fully available through IMAP. + +## Architecture + +The client bridge is a **standalone service** that communicates with the Messages backend exclusively over HTTP (via the REST API). It never accesses the database directly. + +```text +┌──────────────┐ IMAP/SMTP ┌───────────────────┐ HTTP ┌──────────────┐ +│ Email client │ ◄────────────────────â–ș │ Client Bridge │ ◄──────────────────â–ș │ Messages │ +│ (Thunderbird)│ │ (pymap/aiosmtpd) │ │ Backend │ +└──────────────┘ └───────────────────┘ └──────────────┘ +``` + +It provides two protocol servers: + +- **IMAP server** (based on [pymap](https://github.com/icgood/pymap)) — for reading messages and syncing flags +- **SMTP submission server** (based on [aiosmtpd](https://aiosmtpd.readthedocs.io/)) — for sending messages + +### Deployment modes + +Both servers run in a **single process** by default, but can be split: + +| Mode | `ENABLE_IMAP` | `ENABLE_SMTP` | Use case | +|---|---|---|---| +| Combined (default) | `true` | `true` | Simple deployments | +| IMAP only | `true` | `false` | Read-only access, or separate SMTP process | +| SMTP only | `false` | `true` | Dedicated submission relay | + +**Multiple client-bridge instances can connect to the same Messages backend.** The bridge is stateless — all data lives in the Messages API. This means you can scale horizontally or run separate IMAP and SMTP processes behind a load balancer. + +### IMAP + +Uses pymap's pluggable backend system with a custom `messages-api` backend that: + +- **Authenticates** users via Channel app-specific passwords +- **Lists folders** mapped from message flags: INBOX, Sent, Drafts, Trash, Archive, Spam +- **Fetches messages** as raw EML from the Messages API +- **Syncs flags** back to the Messages API when users mark messages as read, starred, etc. + +#### Virtual folders + +Messages has no physical folder model — threads have boolean flags (`is_trashed`, `is_archived`, `is_spam`, etc.) and the web UI renders "folders" as filtered views. The IMAP bridge does the same: each IMAP folder is a virtual view backed by an API filter. + +| IMAP folder | API filter | Notes | +|---|---|---| +| `INBOX` | Active threads (not trashed, archived, or spam) | Default view | +| `Sent` | Threads where the mailbox is the sender | | +| `Drafts` | Threads with a draft message | | +| `Trash` | `is_trashed = true` | | +| `Archive` | `is_archived = true` | | +| `Spam` | `is_spam = true` | | + +**Moving messages** between folders works by toggling these flags via the API. For example, moving a message to Trash sets `is_trashed = true`; moving it back to Inbox clears that flag. Custom/arbitrary folders are not supported. + +#### Limitations + +IMAP is a legacy protocol with inherent limitations. The bridge maps Messages concepts to IMAP as faithfully as possible, but some features (e.g. labels, thread-level operations, real-time collaboration) are not representable in IMAP. + +IMAP APPEND (uploading raw messages into a folder) is not yet supported. Email clients that try to save a copy of sent messages via APPEND will receive a `NO` response — this is harmless because the Messages backend already stores sent messages server-side during the SMTP submission flow. + +### SMTP + +Uses aiosmtpd with AUTH PLAIN/LOGIN to: + +- **Authenticate** users via the same Channel credentials +- **Submit messages** to the Messages API for delivery + +## Authentication + +Two layers of authentication protect the client bridge: + +1. **Service-level**: The bridge authenticates to the Messages API with a shared secret (`CLIENTBRIDGE_API_SECRET`), similar to how the MTA authenticates with `MDA_API_SECRET`. +2. **User-level**: Email clients authenticate with an app-specific password tied to a Channel. + +To set up a channel: + +1. Enable the feature on the backend: + - `FEATURE_CLIENTBRIDGE=True` — enables client-bridge support (channel creation, auth endpoints) + - `FEATURE_MAILBOX_ADMIN_CHANNELS=["client-bridge"]` — makes the channel type visible in the frontend Integrations UI (add `"client-bridge"` alongside any other enabled channel types) +2. A mailbox admin creates a Channel of type "Email client access" in the Integrations tab +3. An app-specific password is automatically generated and displayed once — save it immediately +4. Email clients connect with: + - **Username**: the mailbox email address (e.g. `user@example.com`) + - **Password**: the app-specific password + +The password can be rotated from the channel settings if compromised. Rotation invalidates the previous password immediately. + +### Roles + +Each channel has a **role** (stored in `channel.settings["role"]`) that controls what the email client can do: + +| Role | IMAP | SMTP | Use case | +|---|---|---|---| +| `reader` | Read-only (no flag changes) | No | Monitoring, archiving | +| `editor` | Read + write flags | No | Triage, flag management | +| `sender` (default) | Full access | Yes | Standard email client use | +| `sender_only` | No | Yes | Printers, apps, automated senders | + +Roles are enforced at the API level, at IMAP login (rejects `sender_only`), at SMTP AUTH (rejects `reader` and `editor`), and in IMAP mailbox mode (`reader` gets read-only folders). + +## TLS / SSL + +App-specific passwords are sent over IMAP and SMTP during authentication. **TLS is strongly recommended in production** to protect credentials in transit. + +### Option 1: TLS termination at a reverse proxy (recommended) + +The simplest approach is to terminate TLS at a reverse proxy (HAProxy, Nginx, Traefik, etc.) and forward plaintext traffic to the client bridge on an internal network. This is the same pattern used for the Messages backend itself. + +Example with HAProxy: + +```haproxy +frontend imap-tls + bind *:993 ssl crt /etc/ssl/certs/mail.pem + default_backend client-bridge-imap + +backend client-bridge-imap + server bridge1 client-bridge:143 + +frontend smtp-tls + bind *:465 ssl crt /etc/ssl/certs/mail.pem + default_backend client-bridge-smtp + +backend client-bridge-smtp + server bridge1 client-bridge:587 +``` + +Standard ports for email clients: +- **IMAP**: port `993` (implicit TLS / SSL) +- **SMTP submission**: port `465` (implicit TLS / SSL) or port `587` (STARTTLS) + +### Option 2: Native TLS on the client bridge + +Both pymap (IMAP) and aiosmtpd (SMTP) support TLS natively. Set the following environment variables to enable it: + +| Variable | Description | +|---|---| +| `TLS_CERT` | Path to the TLS certificate file (PEM) | +| `TLS_KEY` | Path to the TLS private key file (PEM) | + +When both are set, IMAP will serve with implicit TLS (typically on port 993) and SMTP will offer STARTTLS (on port 587) or implicit TLS (on port 465). + +> **Note:** Native TLS support is not yet implemented in the server entrypoint. The `TLS_CERT` and `TLS_KEY` variables are reserved for future use. Use a reverse proxy for now. + +### Enforcing TLS for SMTP AUTH + +By default, the SMTP server allows authentication over plaintext connections (`auth_require_tls=False`). This is acceptable when TLS is terminated at a proxy, since the connection between the proxy and the bridge is on a trusted internal network. If the SMTP server is directly exposed to the internet with native TLS, set `auth_require_tls=True` in the aiosmtpd Controller configuration to reject AUTH commands on unencrypted connections. + +## Environment Variables + +### Client-bridge service + +| Variable | Description | Default | +|---|---|---| +| `MESSAGES_API_BASE_URL` | Base URL for the Messages API | (required) | +| `CLIENTBRIDGE_API_SECRET` | Shared secret for service-to-service auth | (required) | +| `ENABLE_IMAP` | Enable IMAP server | `true` | +| `IMAP_HOST` | IMAP server bind host | `0.0.0.0` | +| `IMAP_PORT` | IMAP server bind port | `143` | +| `ENABLE_SMTP` | Enable SMTP server | `true` | +| `SMTP_HOST` | SMTP server bind host | `0.0.0.0` | +| `SMTP_PORT` | SMTP server bind port | `587` | + +### Backend (Django) + +These variables are set on the **Messages backend**, not on the client-bridge service itself. + +| Variable | Description | Default | +|---|---|---| +| `CLIENTBRIDGE_API_SECRET` | Shared secret (must match the client-bridge service) | `""` | +| `CLIENTBRIDGE_SESSION_TIMEOUT` | JWT session lifetime in seconds | `3600` | +| `CLIENTBRIDGE_PUBLIC_CONFIG` | JSON object with IMAP/SMTP connection settings exposed to the frontend | `{}` | + +`CLIENTBRIDGE_PUBLIC_CONFIG` tells the frontend which host, port, and security settings to display when a user creates a client-bridge integration. Without it, the frontend falls back to `window.location.hostname` with default ports (993/587), which is incorrect when the client bridge runs on a separate host. + +```bash +CLIENTBRIDGE_PUBLIC_CONFIG='{"imap_host":"imap.example.com","imap_port":993,"imap_security":"SSL/TLS","smtp_host":"smtp.example.com","smtp_port":587,"smtp_security":"STARTTLS"}' +``` + +| Key | Type | Description | +|-----|------|-------------| +| `imap_host` | string | Hostname for IMAP connections | +| `imap_port` | integer | Port for IMAP connections (typically `993` for implicit TLS, `143` for STARTTLS) | +| `imap_security` | string | Security mode shown to users (e.g. `"SSL/TLS"`, `"STARTTLS"`) | +| `smtp_host` | string | Hostname for SMTP connections | +| `smtp_port` | integer | Port for SMTP connections (typically `465` for implicit TLS, `587` for STARTTLS) | +| `smtp_security` | string | Security mode shown to users (e.g. `"SSL/TLS"`, `"STARTTLS"`) | + +## Development + +From the repository root: + +```bash +# Run tests +make test-client-bridge + +# Lint +make lint-client-bridge + +# Regenerate lock file after changing dependencies +make deps-lock-client-bridge +``` diff --git a/src/client-bridge/entrypoint.sh b/src/client-bridge/entrypoint.sh new file mode 100644 index 000000000..33ccbb7fc --- /dev/null +++ b/src/client-bridge/entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e + +if [ "${EXEC_CMD_ONLY:-false}" = "true" ]; then + exec "$@" +fi + +echo "Starting client-bridge server..." + +# If env var EXEC_CMD is true, run the tests or another command +if [ "${EXEC_CMD:-false}" = "true" ]; then + "$@" + exit $? +fi + +exec python -m src.server diff --git a/src/client-bridge/pyproject.toml b/src/client-bridge/pyproject.toml new file mode 100644 index 000000000..63272e988 --- /dev/null +++ b/src/client-bridge/pyproject.toml @@ -0,0 +1,111 @@ +# +# st-messages-client-bridge package +# +[build-system] +requires = ["uv_build>=0.10.0,<0.11.0"] +build-backend = "uv_build" + +[project] +name = "st-messages-client-bridge" +version = "0.1.0" +authors = [{ "name" = "ANCT", "email" = "contact@suite.anct.gouv.fr" }] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", +] +description = "An IMAP/SMTP client bridge backed by the Messages API, built on pymap and aiosmtpd." +keywords = ["Python", "IMAP", "SMTP", "pymap", "aiosmtpd"] +license = "MIT" +readme = "README.md" +requires-python = ">=3.13,<4.0" + +# Note: after changing this list you must re-run `make deps-lock-client-bridge` +dependencies = [ + "pymap==0.36.7", + "httpx>=0.28", + "aiosmtpd>=1.4.6", + "PyJWT>=2.0", +] + +[project.urls] +"Bug Tracker" = "https://github.com/suitenumerique/st-messages/issues/new" +"Changelog" = "https://github.com/suitenumerique/st-messages/blob/main/CHANGELOG.md" +"Homepage" = "https://github.com/suitenumerique/st-messages" +"Repository" = "https://github.com/suitenumerique/st-messages" + +[project.entry-points."pymap.backend"] +messages-api = "src.backend:MessagesBackend" + +[project.optional-dependencies] +dev = [ + "pytest==8.3.5", + "pytest-cov==6.0.0", + "fastapi==0.115.12", + "uvicorn==0.34.1", + "ruff==0.9.3", + "pylint==3.3.4", +] + +[tool.uv.build-backend] +module-root = "" +source-include = ["src/**"] +source-exclude = ["tests/**"] + +[tool.uv] +package = false + +[tool.ruff] +exclude = [ + ".git", + ".venv", + "build", + "venv", + "__pycache__", +] +line-length = 99 + + +[tool.ruff.lint] +ignore = ["PLR2004"] +select = [ + "B", # flake8-bugbear + "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "I", # isort + "PLC", # pylint-convention + "PLE", # pylint-error + "PLR", # pylint-refactoring + "PLW", # pylint-warning + "RUF100", # Ruff unused-noqa + "RUF200", # Ruff check pyproject.toml + "S", # flake8-bandit + "SLF", # flake8-self + "T20", # flake8-print + "F" # pyflakes +] + +[tool.ruff.lint.isort] +section-order = ["future","standard-library","third-party","first-party","local-folder"] + +[tool.ruff.lint.per-file-ignores] +"**/tests/*" = ["S", "SLF", "BLE"] +"src/mailbox.py" = ["SLF", "BLE", "PLR0913"] + +[tool.pytest.ini_options] +addopts = [ + "-v", + "--cov-report", + "term-missing", + "--import-mode=importlib", +] +filterwarnings = [ + "ignore::pytest.PytestUnhandledThreadExceptionWarning", +] +python_files = [ + "test_*.py", + "tests.py", +] diff --git a/src/client-bridge/src/__init__.py b/src/client-bridge/src/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/client-bridge/src/api/__init__.py b/src/client-bridge/src/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/client-bridge/src/api/client.py b/src/client-bridge/src/api/client.py new file mode 100644 index 000000000..5b95d63ce --- /dev/null +++ b/src/client-bridge/src/api/client.py @@ -0,0 +1,217 @@ +"""Async HTTP client for the Messages API.""" + +import logging +import time + +import httpx +import jwt + +logger = logging.getLogger(__name__) + +# Default transport with retry support +_RETRY_STATUS_CODES = {502, 503, 504} +_MAX_RETRIES = 3 + + +class SessionExpired(Exception): + """Raised when the JWT session token has expired.""" + + +class MessagesAPIClient: + """Async client for interacting with the Messages API over HTTP.""" + + def __init__(self, base_url: str, api_secret: str = ""): + self.base_url = base_url.rstrip("/") + self._api_secret = api_secret + self._service_headers: dict[str, str] = {} + if api_secret: + self._service_headers["X-Service-Auth"] = f"Bearer {api_secret}" + transport = httpx.AsyncHTTPTransport(retries=_MAX_RETRIES) + self._client = httpx.AsyncClient( + transport=transport, + timeout=30.0, + ) + self._token: str | None = None + self._token_exp: float | None = None + + def with_token(self, token: str) -> "MessagesAPIClient": + """Return a new client whose requests include the session JWT. + + The returned client uses a separate httpx.AsyncClient with the + X-Channel-Token default header but WITHOUT the X-Service-Auth + header, so that channel-scoped requests never leak the bridge + secret. + + Also stores the token's expiration so we can fail fast with a + clear error instead of waiting for a 401 from the backend. + """ + clone = object.__new__(MessagesAPIClient) + clone.base_url = self.base_url + clone._api_secret = self._api_secret # noqa: SLF001 + clone._service_headers = {} # noqa: SLF001 + transport = httpx.AsyncHTTPTransport(retries=_MAX_RETRIES) + clone._client = httpx.AsyncClient( # noqa: SLF001 + headers={"X-Channel-Token": token}, + transport=transport, + timeout=30.0, + ) + clone._token = token + # Verify the JWT signature using the shared secret before reading claims + try: + payload = jwt.decode(token, key=self._api_secret, algorithms=["HS256"]) + clone._token_exp = payload.get("exp") + except jwt.InvalidTokenError: + clone._token_exp = None + return clone + + def _check_token(self) -> dict: + """Return extra per-request headers, or raise SessionExpired. + + For token-scoped clients the X-Channel-Token is already set as a + default header on the httpx.AsyncClient, so we only need to check + expiry here. For the service-level client we include the + X-Service-Auth header per-request. + """ + if self._token is None: + return {**self._service_headers} + if self._token_exp is not None and time.time() >= self._token_exp: + raise SessionExpired("Session token has expired. Please re-authenticate.") + return {} + + async def close(self): + """Close the underlying HTTP client.""" + await self._client.aclose() + + async def authenticate_channel(self, username: str, password: str) -> dict | None: + """Authenticate a channel by mailbox email and app-specific password. + + Returns the decoded JWT payload with an extra ``token`` key + (the raw JWT string) if authentication succeeds, or None if it fails. + """ + resp = await self._client.post( + f"{self.base_url}/client-bridge/auth/", + json={"username": username, "password": password}, + headers={**self._service_headers}, + timeout=10, + ) + if resp.status_code != 200: + return None + + token = resp.json()["token"] + # Verify the JWT signature using the shared secret before trusting claims. + payload = jwt.decode(token, key=self._api_secret, algorithms=["HS256"]) + payload["token"] = token + return payload + + async def submit_message( + self, + token: str, + mail_from: str, + rcpt_to: str, + raw_message: bytes, + ) -> dict | None: + """Submit an outbound message through the client-bridge endpoint. + + Posts the raw RFC 5322 message to the backend for delivery. + Uses the session JWT for authentication. + Returns the response dict on success, or None on failure. + """ + resp = await self._client.post( + f"{self.base_url}/client-bridge/submit/", + content=raw_message, + headers={ + **self._service_headers, + "Content-Type": "message/rfc822", + "X-Channel-Token": token, + "X-Mail-From": mail_from, + "X-Rcpt-To": rcpt_to, + }, + timeout=30, + ) + if resp.status_code in (200, 202): + return resp.json() + return None + + async def list_threads( + self, mailbox_id: str, folder: str = "inbox", page: int = 1, page_size: int = 100 + ) -> dict: + """List threads for a mailbox, filtered by folder.""" + params = { + "mailbox_id": mailbox_id, + "page": page, + "page_size": page_size, + } + if folder == "trash": + params["has_trashed"] = "1" + elif folder == "drafts": + params["has_draft"] = "1" + elif folder == "spam": + params["is_spam"] = "1" + elif folder == "archive": + params["has_archived"] = "1" + elif folder == "sent": + params["has_sender"] = "1" + elif folder == "starred": + params["has_starred"] = "1" + + resp = await self._client.get( + f"{self.base_url}/threads/", + params=params, + headers=self._check_token(), + ) + resp.raise_for_status() + return resp.json() + + async def list_messages(self, thread_id: str, mailbox_id: str) -> list[dict]: + """List all messages in a thread.""" + resp = await self._client.get( + f"{self.base_url}/messages/", + params={"thread_id": thread_id, "mailbox_id": mailbox_id}, + headers=self._check_token(), + ) + resp.raise_for_status() + data = resp.json() + return data.get("results", data) if isinstance(data, dict) else data + + async def get_message_eml(self, message_id: str) -> bytes: + """Download a message as raw RFC 5322 EML.""" + resp = await self._client.get( + f"{self.base_url}/messages/{message_id}/eml/", + headers=self._check_token(), + ) + resp.raise_for_status() + return resp.content + + async def change_flag( + self, + flag: str, + value: bool, + mailbox_id: str, + *, + message_ids: list[str] | None = None, + thread_ids: list[str] | None = None, + read_at: str | None = None, + ) -> bool: + """Change a flag on messages or threads via POST /flag/. + + Supported flags: unread, starred, trashed, archived, spam. + For 'unread', read_at must be provided (ISO 8601 timestamp or null). + """ + payload: dict = { + "flag": flag, + "value": value, + "mailbox_id": mailbox_id, + } + if message_ids: + payload["message_ids"] = message_ids + if thread_ids: + payload["thread_ids"] = thread_ids + if flag == "unread": + payload["read_at"] = read_at + resp = await self._client.post( + f"{self.base_url}/flag/", + json=payload, + headers=self._check_token(), + timeout=10, + ) + return resp.status_code == 200 diff --git a/src/client-bridge/src/backend.py b/src/client-bridge/src/backend.py new file mode 100644 index 000000000..08ff1673e --- /dev/null +++ b/src/client-bridge/src/backend.py @@ -0,0 +1,206 @@ +"""Pymap backend that uses the Messages API as its data store.""" + +from __future__ import annotations + +import logging +from argparse import ArgumentParser, Namespace +from collections.abc import AsyncIterator +from contextlib import AsyncExitStack, asynccontextmanager +from datetime import datetime +from secrets import token_bytes +from typing import Any, Final + +from pymap.config import BackendCapability, IMAPConfig +from pymap.exceptions import InvalidAuth, UserNotFound +from pymap.health import HealthStatus +from pymap.interfaces.backend import BackendInterface +from pymap.interfaces.login import IdentityInterface, LoginInterface +from pymap.interfaces.token import TokenCredentials +from pymap.token import AllTokens +from pymap.user import UserMetadata +from pysasl.creds.plain import PlainCredentials +from pysasl.creds.server import ServerCredentials + +from .api.client import MessagesAPIClient +from .mailbox import MailboxSet +from .session import Session + +__all__ = ["MessagesBackend"] + +logger = logging.getLogger(__name__) + + +class MessagesBackend(BackendInterface): + """Pymap backend that delegates to the Messages API over HTTP.""" + + def __init__(self, login: Login, config: Config) -> None: + super().__init__() + self._login = login + self._config = config + self._status = HealthStatus() + + @property + def login(self) -> Login: + return self._login + + @property + def config(self) -> Config: + return self._config + + @property + def status(self) -> HealthStatus: + return self._status + + @classmethod + def add_subparser(cls, name: str, subparsers: Any) -> ArgumentParser: + parser: ArgumentParser = subparsers.add_parser(name, help="Messages API backend") + from src import settings + + parser.add_argument( + "--api-url", + default=settings.MESSAGES_API_BASE_URL, + metavar="URL", + help="Base URL for the Messages API", + ) + return parser + + @classmethod + async def init(cls, args: Namespace, **overrides: Any) -> tuple[MessagesBackend, Config]: + config = Config.from_args(args, **overrides) + api_secret = getattr(args, "api_secret", "") or "" + api_client = MessagesAPIClient(config.api_url, api_secret=api_secret) + login = Login(config, api_client) + return cls(login, config), config + + async def start(self, stack: AsyncExitStack) -> None: + # Register the shared API client for cleanup so HTTP connections + # are properly closed on shutdown. + stack.push_async_callback(self._login.api_client.close) + + +class Config(IMAPConfig): + """Config for the Messages API backend.""" + + def __init__( + self, args: Namespace, *, api_url: str, admin_key: bytes | None = None, **extra: Any + ) -> None: + admin_key = admin_key or token_bytes() + super().__init__(args, admin_key=admin_key, **extra) + self._api_url = api_url + + @property + def backend_capability(self) -> BackendCapability: + return BackendCapability(idle=False, object_id=True, multi_append=False) + + @property + def api_url(self) -> str: + return self._api_url + + @classmethod + def parse_args(cls, args: Namespace) -> dict[str, Any]: + return {**super().parse_args(args), "api_url": args.api_url} + + +class Login(LoginInterface): + """Login implementation that authenticates against channel app-specific passwords.""" + + def __init__(self, config: Config, api_client: MessagesAPIClient) -> None: + super().__init__() + self.config: Final = config + self.api_client: Final = api_client + self._tokens = AllTokens(config) + + @property + def tokens(self) -> AllTokens: + return self._tokens + + async def authenticate(self, credentials: ServerCredentials) -> Identity: + authcid = credentials.authcid + + if isinstance(credentials, TokenCredentials): + raise InvalidAuth("Token authentication not supported.") + + # Extract the cleartext password from the credentials + if isinstance(credentials, PlainCredentials): + password = credentials._secret # noqa: SLF001 + else: + raise InvalidAuth("Only PLAIN/LOGIN authentication is supported.") + + if not password: + raise InvalidAuth() + + # Authenticate via the Messages API + channel_data = await self.api_client.authenticate_channel(authcid, password) + if channel_data is None: + raise InvalidAuth() + + # Reject channels without read access (e.g. sender_only) + role = channel_data.get("role", "sender") + if role == "sender_only": + logger.warning("IMAP auth rejected for %s: sender_only has no read access", authcid) + raise InvalidAuth() + + logger.info("Authenticated channel %s (role=%s)", authcid, role) + return Identity( + name=authcid, + login=self, + channel_data=channel_data, + token=channel_data.get("token", ""), + ) + + async def authorize(self, authenticated: IdentityInterface, authzid: str) -> Identity: + # An empty authzid means "authorize as self" (RFC 4616 §2). + if authzid and authenticated.name != authzid: + raise InvalidAuth("Authorization as a different user is not supported.") + if not isinstance(authenticated, Identity): + raise InvalidAuth() + return authenticated + + +class Identity(IdentityInterface): + """Identity representing an authenticated channel.""" + + def __init__(self, name: str, login: Login, channel_data: dict, token: str = "") -> None: + super().__init__() + self._name = name + self._login = login + self._channel_data = channel_data + self._token = token + + @property + def name(self) -> str: + return self._name + + @property + def roles(self) -> frozenset[str]: + return frozenset() + + @property + def role(self) -> str: + return self._channel_data.get("role", "sender") + + @property + def token(self) -> str: + return self._token + + @asynccontextmanager + async def new_session(self) -> AsyncIterator[Session]: + mailbox_id = self._channel_data["mailbox_id"] + api_client = self._login.api_client.with_token(self._token) + try: + mailbox_set = MailboxSet(api_client, mailbox_id, role=self.role) + yield Session(self._name, self._login.config, mailbox_set) + finally: + await api_client.close() + + async def new_token(self, *, expiration: datetime | None = None) -> str | None: + return None + + async def get(self) -> UserMetadata: + return UserMetadata(self._login.config, self._name) + + async def set(self, metadata: UserMetadata) -> int | None: + raise UserNotFound() + + async def delete(self) -> None: + raise UserNotFound() diff --git a/src/client-bridge/src/mailbox.py b/src/client-bridge/src/mailbox.py new file mode 100644 index 000000000..5dd89ab59 --- /dev/null +++ b/src/client-bridge/src/mailbox.py @@ -0,0 +1,642 @@ +"""Mailbox and message implementations backed by the Messages API.""" + +from __future__ import annotations + +import email.parser +import logging +from collections.abc import AsyncIterable, Iterable +from datetime import datetime, timedelta, timezone + +from pymap.backend.mailbox import MailboxDataInterface, MailboxSetInterface +from pymap.concurrent import Event, ReadWriteLock +from pymap.context import subsystem +from pymap.exceptions import CloseConnection, NotSupportedError +from pymap.flags import FlagOp +from pymap.interfaces.message import CachedMessage +from pymap.listtree import ListTree +from pymap.mailbox import MailboxSnapshot +from pymap.message import BaseLoadedMessage, BaseMessage +from pymap.mime import MessageContent +from pymap.parsing.message import AppendMessage +from pymap.parsing.specials import FetchRequirement, ObjectId +from pymap.parsing.specials.flag import Deleted, Flag, Flagged, Seen +from pymap.selected import SelectedMailbox, SelectedSet + +from .api.client import MessagesAPIClient, SessionExpired + +__all__ = ["Message", "MailboxData", "MailboxSet"] + +logger = logging.getLogger(__name__) + +# Virtual folder names mapped to Messages API filters +VIRTUAL_FOLDERS = { + "INBOX": "inbox", + "Sent": "sent", + "Drafts": "drafts", + "Trash": "trash", + "Archive": "archive", + "Spam": "spam", + "Starred": "starred", +} + + +class Message(BaseMessage): + """A message loaded from the Messages API.""" + + __slots__ = [ + "_api_message_id", + "_api_thread_id", + "_api_client", + "_content", + "_mime_id", + "_recent", + ] + + def __init__( + self, + uid: int, + internal_date: datetime, + permanent_flags: Iterable[Flag], + *, + api_message_id: str, + api_thread_id: str = "", + api_client: MessagesAPIClient | None = None, + expunged: bool = False, + email_id: ObjectId | None = None, + thread_id: ObjectId | None = None, + recent: bool = False, + content: MessageContent | None = None, + mime_id: str = "", + ) -> None: + super().__init__( + uid, + internal_date, + permanent_flags, + expunged=expunged, + email_id=email_id, + thread_id=thread_id, + ) + self._api_message_id = api_message_id + self._api_thread_id = api_thread_id + self._api_client = api_client + self._content = content + self._mime_id = mime_id + self._recent = recent + + @property + def api_message_id(self) -> str: + return self._api_message_id + + @property + def api_thread_id(self) -> str: + return self._api_thread_id + + @property + def recent(self) -> bool: + return self._recent + + @recent.setter + def recent(self, recent: bool) -> None: + self._recent = recent + + async def load_content(self, requirement: FetchRequirement) -> LoadedMessage: + # Lazy-load EML content on first FETCH that needs the body + if self._content is None and requirement != FetchRequirement.NONE: + if self._api_client is not None: + try: + eml_data = await self._api_client.get_message_eml(self._api_message_id) + self._content = MessageContent.parse(eml_data) + except Exception: + logger.debug("Could not load EML for message %s", self._api_message_id) + return LoadedMessage(self, requirement, self._content) + + +class LoadedMessage(BaseLoadedMessage): + pass + + +def _parse_date(date_str: str | None) -> datetime: + """Parse an ISO 8601 date string, falling back to now.""" + if not date_str: + return datetime.now(timezone.utc) + try: + return datetime.fromisoformat(date_str) + except (ValueError, TypeError): + return datetime.now(timezone.utc) + + +def _flags_from_api(msg: dict) -> frozenset[Flag]: + """Convert Messages API flags to IMAP flags. + + ``\\Deleted`` is intentionally NOT derived from ``is_trashed``. + In IMAP ``\\Deleted`` means "permanently remove on EXPUNGE" and must + only be set by an explicit STORE command from the client. Trashed + messages are instead filtered out during ingestion so they only + appear in the Trash virtual folder. + """ + flags: set[Flag] = set() + if not msg.get("is_unread", True): + flags.add(Seen) + if msg.get("is_starred", False): + flags.add(Flagged) + return frozenset(flags) + + +class MailboxData(MailboxDataInterface[Message]): + """Mailbox data backed by a Messages API folder view.""" + + def __init__( + self, + api_client: MessagesAPIClient, + mailbox_id: str, + folder: str, + *, + readonly: bool = False, + ) -> None: + self._api_client = api_client + self._api_mailbox_id = mailbox_id + self._folder = folder + self._mailbox_id = ObjectId.random_mailbox_id() + self._readonly = readonly + self._uid_validity = MailboxSnapshot.new_uid_validity() + self._selected_set = SelectedSet() + self._messages_lock = subsystem.get().new_rwlock() + self._updated = subsystem.get().new_event() + # Cache: uid -> Message + self._messages: dict[int, Message] = {} + self._api_id_to_uid: dict[str, int] = {} + self._max_uid = 0 + self._loaded = False + + @property + def mailbox_id(self) -> ObjectId: + return self._mailbox_id + + @property + def readonly(self) -> bool: + return self._readonly + + @property + def uid_validity(self) -> int: + return self._uid_validity + + @property + def messages_lock(self) -> ReadWriteLock: + return self._messages_lock + + @property + def selected_set(self) -> SelectedSet: + return self._selected_set + + async def _fetch_threads(self) -> list[dict]: + """Fetch all threads for this folder across all pages.""" + threads: list[dict] = [] + page = 1 + while True: + threads_data = await self._api_client.list_threads( + self._api_mailbox_id, self._folder, page=page + ) + page_threads = threads_data.get("results", threads_data) + if isinstance(page_threads, dict): + page_threads = page_threads.get("results", []) + if not page_threads: + break + threads.extend(page_threads) + total = threads_data.get("count", 0) + if len(threads) >= total: + break + page += 1 + return threads + + async def _ingest_threads(self, threads: list[dict]) -> int: + """Ingest messages from threads into the local cache. + + Skips messages already known. Returns the number of new messages added. + """ + added = 0 + for thread in threads: + thread_id = thread.get("id") + if not thread_id: + continue + try: + api_messages = await self._api_client.list_messages( + thread_id, self._api_mailbox_id + ) + except SessionExpired: + raise + except Exception: + logger.exception("Failed to load messages for thread %s", thread_id) + continue + + for api_msg in api_messages: + msg_id = api_msg.get("id") + if not msg_id or msg_id in self._api_id_to_uid: + continue + + self._max_uid += 1 + uid = self._max_uid + + flags = _flags_from_api(api_msg) + internal_date = _parse_date(api_msg.get("sent_at") or api_msg.get("created_at")) + + # EML content is loaded lazily on FETCH + message = Message( + uid=uid, + internal_date=internal_date, + permanent_flags=flags, + api_message_id=msg_id, + api_thread_id=thread_id, + api_client=self._api_client, + email_id=ObjectId.random_email_id(), + thread_id=ObjectId.random_thread_id(), + recent=True, + mime_id=api_msg.get("mime_id") or "", + ) + self._messages[uid] = message + self._api_id_to_uid[msg_id] = uid + added += 1 + return added + + async def _load_messages(self) -> None: + """Initial load of message metadata from the Messages API. + + Only fetches thread/message listings — EML content is loaded lazily + when a client issues a FETCH command that requires the body. + """ + if self._loaded: + return + try: + threads = await self._fetch_threads() + await self._ingest_threads(threads) + except SessionExpired: + logger.info("Session expired during message load for folder %s", self._folder) + raise CloseConnection() + except Exception: + logger.exception("Failed to load messages for folder %s", self._folder) + self._loaded = True + + async def _refresh_messages(self) -> None: + """Check for new messages since the last load and add them to the cache.""" + try: + threads = await self._fetch_threads() + added = await self._ingest_threads(threads) + if added: + logger.debug("Refreshed folder %s: %d new messages", self._folder, added) + self._updated.set() + self._updated.clear() + except SessionExpired: + logger.info("Session expired during refresh for folder %s", self._folder) + raise CloseConnection() + except Exception: + logger.exception("Failed to refresh messages for folder %s", self._folder) + + async def update_selected( + self, selected: SelectedMailbox, *, wait_on: Event | None = None + ) -> SelectedMailbox: + if not self._loaded: + await self._load_messages() + else: + # Refresh: check for new messages on NOOP/CHECK + await self._refresh_messages() + if wait_on is not None: + either_event = wait_on.or_event(self._updated) + await either_event.wait() + all_messages = list(self._messages.values()) + selected.set_messages(all_messages) + return selected + + async def append(self, append_msg: AppendMessage, *, recent: bool = False) -> Message: + if self._folder == "sent": + # Thunderbird (and other MUAs) APPEND a copy of outgoing mail to + # the Sent folder right after SMTP submission. The Messages + # backend already stores sent messages, so we silently accept the + # APPEND by matching on Message-ID. + parser = email.parser.BytesParser() + parsed = parser.parsebytes(append_msg.literal, headersonly=True) + raw_mid = parsed.get("Message-ID", "") + # Strip angle brackets: "" -> "foo@bar" + mime_id = raw_mid.strip().strip("<>") + + if mime_id: + await self._refresh_messages() + for msg in self._messages.values(): + if msg._mime_id == mime_id: + msg.recent = recent + return msg + + logger.debug( + "APPEND to Sent ignored: no matching message found for Message-ID %s", + mime_id, + ) + + raise NotSupportedError( + "IMAP APPEND is not yet supported. " + "Use the Messages web interface or SMTP submission to create messages." + ) + + async def copy( + self, uid: int, destination: MailboxData, *, recent: bool = False + ) -> int | None: + """Copy a message to another folder. + + Note: The Messages API does not have a server-side copy endpoint. + This creates a local in-memory copy in the destination folder for + the duration of the IMAP session. The copy is NOT persisted to + the backend — it will disappear when the session ends. + """ + async with self.messages_lock.read_lock(): + source = self._messages.get(uid) + if source is None: + return None + + # Persist flag changes that represent the destination folder. + # For example, copying to Trash should mark as trashed in the API. + dest_flags = self._folder_flags_for(destination._folder) # noqa: SLF001 + for flag_name, flag_value in dest_flags: + try: + await self._api_client.change_flag( + flag_name, + value=flag_value, + mailbox_id=self._api_mailbox_id, + message_ids=[source.api_message_id], + ) + except Exception: + logger.warning( + "Failed to persist copy flags for message %s", source.api_message_id + ) + + async with destination.messages_lock.write_lock(): + destination._max_uid += 1 # noqa: SLF001 + dest_uid = destination._max_uid # noqa: SLF001 + new_msg = Message( + uid=dest_uid, + internal_date=source.internal_date, + permanent_flags=source.permanent_flags, + api_message_id=source.api_message_id, + api_thread_id=source.api_thread_id, + email_id=source.email_id, + thread_id=source.thread_id, + recent=recent, + content=source._content, # noqa: SLF001 + mime_id=source._mime_id, # noqa: SLF001 + ) + destination._messages[dest_uid] = new_msg # noqa: SLF001 + destination._updated.set() # noqa: SLF001 + return dest_uid + + @staticmethod + def _folder_flags_for(folder: str) -> list[tuple[str, bool]]: + """Return (flag_name, value) pairs for moving into the given folder.""" + mapping: dict[str, list[tuple[str, bool]]] = { + "trash": [("trashed", True)], + "spam": [("spam", True)], + "archive": [("archived", True)], + } + return mapping.get(folder, []) + + async def move( + self, uid: int, destination: MailboxData, *, recent: bool = False + ) -> int | None: + async with self.messages_lock.write_lock(): + source = self._messages.pop(uid, None) + if source is None: + return None + self._updated.set() + + # Persist the move to the API by updating folder-related flags. + # Clear flags from the source folder and set flags for the destination. + clear_flags = self._folder_flags_for(self._folder) + set_flags = self._folder_flags_for(destination._folder) # noqa: SLF001 + all_flag_changes: list[tuple[str, bool]] = [ + (flag_name, False) for flag_name, _ in clear_flags + ] + all_flag_changes.extend(set_flags) + for flag_name, flag_value in all_flag_changes: + try: + await self._api_client.change_flag( + flag_name, + value=flag_value, + mailbox_id=self._api_mailbox_id, + message_ids=[source.api_message_id], + ) + except Exception: + logger.warning( + "Failed to persist move flags for message %s", source.api_message_id + ) + + async with destination.messages_lock.write_lock(): + destination._max_uid += 1 # noqa: SLF001 + dest_uid = destination._max_uid # noqa: SLF001 + new_msg = Message( + uid=dest_uid, + internal_date=source.internal_date, + permanent_flags=source.permanent_flags, + api_message_id=source.api_message_id, + api_thread_id=source.api_thread_id, + email_id=source.email_id, + thread_id=source.thread_id, + recent=recent, + content=source._content, # noqa: SLF001 + mime_id=source._mime_id, # noqa: SLF001 + ) + destination._messages[dest_uid] = new_msg # noqa: SLF001 + destination._updated.set() # noqa: SLF001 + return dest_uid + + async def get(self, uid: int, cached_msg: CachedMessage) -> Message: + await self._load_messages() + msg = self._messages.get(uid) + if msg is None: + if isinstance(cached_msg, Message): + return Message( + uid=cached_msg.uid, + internal_date=cached_msg.internal_date, + permanent_flags=cached_msg.permanent_flags, + api_message_id=cached_msg.api_message_id, + api_thread_id=cached_msg.api_thread_id, + expunged=True, + email_id=cached_msg.email_id, + thread_id=cached_msg.thread_id, + mime_id=cached_msg._mime_id, # noqa: SLF001 + ) + raise IndexError(uid) + return msg + + async def update( + self, + uid: int, + cached_msg: CachedMessage, + flag_set: frozenset[Flag], + mode: FlagOp, + ) -> Message: + msg = await self.get(uid, cached_msg) + old_flags = msg.permanent_flags + msg.permanent_flags = mode.apply(msg.permanent_flags, flag_set) + self._updated.set() + + new_flags = msg.permanent_flags + try: + # Persist read/unread via the unread flag endpoint. + # The API uses read_at on ThreadAccess: messages created at or + # before read_at are read, those after are unread. + if (Seen in new_flags) != (Seen in old_flags): + is_read = Seen in new_flags + if is_read: + # Mark as read: set read_at to the message's date + read_at = msg.internal_date.isoformat() + else: + # Mark as unread: set read_at to 1 second before the + # message's date so this message and newer ones become + # unread while older messages remain read. + read_at_dt = msg.internal_date - timedelta(seconds=1) + read_at = read_at_dt.isoformat() + await self._api_client.change_flag( + "unread", + value=not is_read, + mailbox_id=self._api_mailbox_id, + thread_ids=[msg.api_thread_id], + read_at=read_at, + ) + # Persist starred + if (Flagged in new_flags) != (Flagged in old_flags): + await self._api_client.change_flag( + "starred", + value=Flagged in new_flags, + mailbox_id=self._api_mailbox_id, + message_ids=[msg.api_message_id], + ) + # Persist trashed + if (Deleted in new_flags) != (Deleted in old_flags): + await self._api_client.change_flag( + "trashed", + value=Deleted in new_flags, + mailbox_id=self._api_mailbox_id, + message_ids=[msg.api_message_id], + ) + except Exception: + logger.warning("Failed to persist flags for message %s", msg.api_message_id) + + return msg + + async def delete(self, uids: Iterable[int]) -> None: + async with self.messages_lock.write_lock(): + for uid in uids: + msg = self._messages.pop(uid, None) + if msg is not None: + try: + await self._api_client.change_flag( + "trashed", + value=True, + mailbox_id=self._api_mailbox_id, + message_ids=[msg.api_message_id], + ) + except Exception: + logger.warning( + "Failed to persist delete for message %s", msg.api_message_id + ) + self._updated.set() + + async def claim_recent(self, selected: SelectedMailbox) -> None: + await self._load_messages() + for msg in self._messages.values(): + if msg.recent: + msg.recent = False + selected.session_flags.add_recent(msg.uid) + + async def cleanup(self) -> None: + pass + + async def messages(self) -> AsyncIterable[Message]: + await self._load_messages() + async with self.messages_lock.read_lock(): + for msg in self._messages.values(): + yield msg + + async def snapshot(self) -> MailboxSnapshot: + await self._load_messages() + exists = 0 + recent = 0 + unseen = 0 + first_unseen: int | None = None + next_uid = self._max_uid + 1 + async for msg in self.messages(): + exists += 1 + if msg.recent: + recent += 1 + if Seen not in msg.permanent_flags: + unseen += 1 + if first_unseen is None: + first_unseen = exists + return MailboxSnapshot( + self.mailbox_id, + self.readonly, + self.uid_validity, + self.permanent_flags, + self.session_flags, + exists, + recent, + unseen, + first_unseen, + next_uid, + ) + + +class MailboxSet(MailboxSetInterface[MailboxData]): + """Set of virtual mailboxes backed by Messages API folder views.""" + + def __init__( + self, api_client: MessagesAPIClient, mailbox_id: str, *, role: str = "sender" + ) -> None: + super().__init__() + self._api_client = api_client + self._mailbox_id = mailbox_id + self._role = role + self._set_lock = subsystem.get().new_rwlock() + self._subscribed: dict[str, bool] = {name: True for name in VIRTUAL_FOLDERS} + # Lazily create folder data + self._folders: dict[str, MailboxData] = {} + + def _get_or_create_folder(self, name: str) -> MailboxData: + if name not in self._folders: + folder_key = VIRTUAL_FOLDERS.get(name, "inbox") + self._folders[name] = MailboxData( + self._api_client, + self._mailbox_id, + folder_key, + readonly=(self._role == "reader"), + ) + return self._folders[name] + + @property + def delimiter(self) -> str: + return "/" + + async def set_subscribed(self, name: str, subscribed: bool) -> None: + async with self._set_lock.write_lock(): + self._subscribed[name] = subscribed + + async def list_subscribed(self) -> ListTree: + async with self._set_lock.read_lock(): + names = [n for n, s in self._subscribed.items() if s] + return ListTree(self.delimiter).update(*names) + + async def list_mailboxes(self) -> ListTree: + return ListTree(self.delimiter).update(*VIRTUAL_FOLDERS.keys()) + + async def get_mailbox(self, name: str) -> MailboxData: + # Normalize INBOX + lookup = name + if name.upper() == "INBOX": + lookup = "INBOX" + if lookup not in VIRTUAL_FOLDERS: + raise KeyError(name) + return self._get_or_create_folder(lookup) + + async def add_mailbox(self, name: str) -> ObjectId: + raise ValueError("Cannot create mailboxes on an API-backed server.") + + async def delete_mailbox(self, name: str) -> None: + raise KeyError("Cannot delete mailboxes on an API-backed server.") + + async def rename_mailbox(self, before: str, after: str) -> None: + raise KeyError("Cannot rename mailboxes on an API-backed server.") diff --git a/src/client-bridge/src/server.py b/src/client-bridge/src/server.py new file mode 100644 index 000000000..3f1efd529 --- /dev/null +++ b/src/client-bridge/src/server.py @@ -0,0 +1,121 @@ +"""Unified entrypoint for the client-bridge service. + +Starts IMAP and/or SMTP servers based on settings +in a single asyncio event loop. +""" + +import asyncio +import logging +import sys +from argparse import Namespace +from contextlib import AsyncExitStack + +from src import settings + +logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(name)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +async def _start_imap(stack: AsyncExitStack, api_url: str, api_secret: str, host: str, port: int): + """Start the pymap IMAP server.""" + from pymap.backend import backends + from pymap.service import services + + from src.backend import MessagesBackend + + backends.add("messages-api", MessagesBackend) + + args = Namespace( + host=host, + port=port, + debug=True, + cert=None, + key=None, + tls=False, + passlib_cfg=None, + proxy_protocol=None, + inherited_sockets=None, + api_url=api_url, + api_secret=api_secret, + backend="messages-api", + ) + + backend, config = await MessagesBackend.init(args) + config.apply_context() + + service_types = list(services.values()) + svc_instances = [svc_type(backend, config) for svc_type in service_types] + + await backend.start(stack) + for service in svc_instances: + await service.start(stack) + + logger.info("IMAP server started on %s:%d", host, port) + + +async def _start_smtp(stack: AsyncExitStack, api_url: str, api_secret: str, host: str, port: int): + """Start the aiosmtpd SMTP submission server.""" + from aiosmtpd.controller import Controller + + from src.api.client import MessagesAPIClient + from src.submission import SubmissionAuthenticator, SubmissionHandler + + api_client = MessagesAPIClient(api_url, api_secret=api_secret) + handler = SubmissionHandler(api_client) + authenticator = SubmissionAuthenticator(api_client) + + controller = Controller( + handler, + hostname=host, + port=port, + authenticator=authenticator, + auth_require_tls=False, + auth_required=True, + timeout=settings.SMTP_SESSION_TIMEOUT, + command_call_limit={"DATA": settings.SMTP_MAX_MESSAGES_PER_SESSION}, + ) + controller.start() + stack.callback(controller.stop) + + logger.info("SMTP submission server started on %s:%d", host, port) + + +async def main(): + """Main entrypoint: start IMAP and/or SMTP based on settings.""" + if not settings.ENABLE_IMAP and not settings.ENABLE_SMTP: + logger.error("Both IMAP and SMTP bridges are disabled. Nothing to do.") + sys.exit(1) + + async with AsyncExitStack() as stack: + if settings.ENABLE_IMAP: + await _start_imap( + stack, + settings.MESSAGES_API_BASE_URL, + settings.CLIENTBRIDGE_API_SECRET, + settings.IMAP_HOST, + settings.IMAP_PORT, + ) + else: + logger.info("IMAP bridge disabled") + + if settings.ENABLE_SMTP: + await _start_smtp( + stack, + settings.MESSAGES_API_BASE_URL, + settings.CLIENTBRIDGE_API_SECRET, + settings.SMTP_HOST, + settings.SMTP_PORT, + ) + else: + logger.info("SMTP bridge disabled") + + # Run forever + try: + while True: + await asyncio.sleep(3600) + except asyncio.CancelledError: + logger.info("Shutting down client-bridge...") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/client-bridge/src/session.py b/src/client-bridge/src/session.py new file mode 100644 index 000000000..17a0193b8 --- /dev/null +++ b/src/client-bridge/src/session.py @@ -0,0 +1,36 @@ +"""IMAP session implementation for the Messages API backend.""" + +from __future__ import annotations + +from typing import Any + +from pymap.backend.session import BaseSession +from pymap.config import IMAPConfig +from pymap.interfaces.filter import FilterSetInterface + +from .mailbox import MailboxSet, Message + + +class Session(BaseSession[Message]): + """Session for the Messages API backend. + + Reuses pymap's BaseSession which provides default implementations for all + session operations based on the MailboxSet/MailboxData interfaces. + """ + + def __init__(self, owner: str, config: IMAPConfig, mailbox_set: MailboxSet) -> None: + super().__init__(owner) + self._config = config + self._mailbox_set = mailbox_set + + @property + def config(self) -> IMAPConfig: + return self._config + + @property + def mailbox_set(self) -> MailboxSet: + return self._mailbox_set + + @property + def filter_set(self) -> FilterSetInterface[Any] | None: + return None diff --git a/src/client-bridge/src/settings.py b/src/client-bridge/src/settings.py new file mode 100644 index 000000000..0d325dda8 --- /dev/null +++ b/src/client-bridge/src/settings.py @@ -0,0 +1,40 @@ +"""Client-bridge settings loaded from environment variables.""" + +import os + + +def _env_bool(name: str, default: bool = True) -> bool: + """Read a boolean from an environment variable.""" + val = os.environ.get(name, "").strip().lower() + if val in ("0", "false", "no"): + return False + if val in ("1", "true", "yes"): + return True + return default + + +def _env_int(name: str, default: int) -> int: + """Read an integer from an environment variable with a clear error message.""" + raw = os.environ.get(name, "") + if not raw: + return default + try: + return int(raw) + except ValueError: + raise ValueError(f"Environment variable {name} must be an integer, got {raw!r}") from None + + +MESSAGES_API_BASE_URL = os.environ.get("MESSAGES_API_BASE_URL", "http://localhost:8000/api/v1.0/") +CLIENTBRIDGE_API_SECRET = os.environ.get("CLIENTBRIDGE_API_SECRET", "") + +ENABLE_IMAP = _env_bool("ENABLE_IMAP", default=True) +IMAP_HOST = os.environ.get("IMAP_HOST", "0.0.0.0") +IMAP_PORT = _env_int("IMAP_PORT", 143) + +ENABLE_SMTP = _env_bool("ENABLE_SMTP", default=True) +SMTP_HOST = os.environ.get("SMTP_HOST", "0.0.0.0") +SMTP_PORT = _env_int("SMTP_PORT", 587) +# Idle timeout in seconds — aiosmtpd drops the connection after this much inactivity +SMTP_SESSION_TIMEOUT = _env_int("SMTP_SESSION_TIMEOUT", 300) +# Max DATA commands per session before aiosmtpd drops the connection +SMTP_MAX_MESSAGES_PER_SESSION = _env_int("SMTP_MAX_MESSAGES_PER_SESSION", 50) diff --git a/src/client-bridge/src/submission.py b/src/client-bridge/src/submission.py new file mode 100644 index 000000000..bdf3613c3 --- /dev/null +++ b/src/client-bridge/src/submission.py @@ -0,0 +1,141 @@ +"""SMTP submission handler using aiosmtpd. + +Authenticates clients via the Messages API and submits outbound +messages through the client-bridge endpoint. +""" + +import logging +import time + +import httpx +import jwt +from aiosmtpd.smtp import AuthResult, LoginPassword, SMTP + +from .api.client import MessagesAPIClient + +logger = logging.getLogger(__name__) + + +class SubmissionAuthenticator: + """Synchronous authenticator callback for aiosmtpd. + + aiosmtpd's ``_authenticate`` is not async-safe — it calls the + authenticator synchronously and does not await coroutines. + We therefore use ``httpx.Client`` (sync) for the auth HTTP call. + """ + + def __init__(self, api_client: MessagesAPIClient): + self._api_url = api_client.base_url + self._headers = dict(api_client._service_headers) + + def __call__(self, server: SMTP, session, envelope, mechanism, auth_data): + if not isinstance(auth_data, LoginPassword): + return AuthResult(success=False, handled=False) + + username = auth_data.login.decode("utf-8", errors="replace") + password = auth_data.password.decode("utf-8", errors="replace") + + # Sync HTTP call — aiosmtpd's _authenticate is not async + try: + with httpx.Client(headers=self._headers, timeout=10) as client: + resp = client.post( + f"{self._api_url}/client-bridge/auth/", + json={"username": username, "password": password}, + ) + except httpx.HTTPError: + logger.exception("SMTP auth HTTP error for %s", username) + return AuthResult(success=False, handled=False) + + if resp.status_code != 200: + logger.warning("SMTP auth failed for %s", username) + return AuthResult(success=False, handled=False) + + token = resp.json().get("token") + if not token: + logger.warning("SMTP auth: no token in response for %s", username) + return AuthResult(success=False, handled=False) + + # The JWT was already verified by the backend auth endpoint during + # the POST to /client-bridge/auth/ above. We only decode here + # (without re-verifying) to extract claims for role-based access + # control and logging/routing. This is safe because the token + # was received directly from a trusted backend response over the + # internal network — it has not been through an untrusted party. + try: + payload = jwt.decode(token, options={"verify_signature": False}) + except jwt.InvalidTokenError: + logger.warning("SMTP auth: invalid JWT for %s", username) + return AuthResult(success=False, handled=False) + payload["token"] = token + + # Reject channels without send access + role = payload.get("role", "sender") + if role not in ("sender", "sender_only"): + logger.warning("SMTP auth rejected for %s: role %r has no send access", username, role) + return AuthResult(success=False, handled=False) + + logger.info( + "SMTP auth success for channel_id=%s (role=%s)", + payload.get("channel_id"), + role, + ) + return AuthResult( + success=True, + handled=False, + auth_data=payload, + ) + + +class SubmissionHandler: + """aiosmtpd handler that processes submitted messages + by forwarding them to the Messages API.""" + + def __init__(self, api_client: MessagesAPIClient): + self.api_client = api_client + + async def handle_RCPT(self, server, session, envelope, address, rcpt_options): + """Accept any recipient address.""" + envelope.rcpt_tos.append(address) + return "250 OK" + + async def handle_DATA(self, server, session, envelope): + """Forward the submitted message to the Messages API.""" + auth_data = session.auth_data + if not auth_data: + return "530 5.7.0 Authentication required" + + token = auth_data.get("token") + if not token: + return "451 Internal error: missing session token" + + # Check token expiry before hitting the backend + exp = auth_data.get("exp") + if exp and time.time() >= exp: + logger.warning( + "SMTP session token expired for channel %s", auth_data.get("channel_id") + ) + return "421 4.7.0 Session expired, please re-authenticate" + + mail_from = envelope.mail_from or "" + rcpt_to = ",".join(envelope.rcpt_tos) if envelope.rcpt_tos else "" + + if not rcpt_to: + return "554 5.1.1 No recipients" + + result = await self.api_client.submit_message( + token=token, + mail_from=mail_from, + rcpt_to=rcpt_to, + raw_message=envelope.content, + ) + + if result is None: + logger.error( + "Message submission failed for channel %s", + auth_data.get("channel_id"), + ) + return "451 4.3.0 Message submission failed" + + msg_id = result.get("message_id", "unknown") + logger.info("Message submitted: id=%s, channel=%s", msg_id, auth_data.get("channel_id")) + return f"250 OK id={msg_id}" diff --git a/src/client-bridge/tests/__init__.py b/src/client-bridge/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/client-bridge/tests/conftest.py b/src/client-bridge/tests/conftest.py new file mode 100644 index 000000000..4663d74a3 --- /dev/null +++ b/src/client-bridge/tests/conftest.py @@ -0,0 +1,646 @@ +"""Test fixtures for client-bridge tests. + +Provides a mock Messages API server and manages the pymap IMAP server +and aiosmtpd SMTP server lifecycle. +""" + +import asyncio +import imaplib +import logging +import os +import smtplib +import socket +import threading +import time +import uuid +from datetime import datetime, timedelta, timezone +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import jwt as pyjwt +import pytest +import uvicorn +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, Response + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +IMAP_HOST = os.getenv("IMAP_HOST", "localhost") +IMAP_PORT = int(os.getenv("IMAP_PORT", "1143")) +SMTP_HOST = os.getenv("SMTP_HOST", "localhost") +SMTP_PORT = int(os.getenv("SMTP_PORT", "1587")) +MOCK_API_PORT = int(os.getenv("MOCK_API_PORT", "8765")) +TEST_API_SECRET = os.getenv("CLIENTBRIDGE_API_SECRET", "test-secret-that-is-at-least-32-bytes") + + +def _make_sample_eml( + subject="Test Subject", + sender="sender@example.com", + to="recipient@example.com", + body="This is a test email body.", + msg_id=None, +): + """Create a sample EML message.""" + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = sender + msg["To"] = to + msg["Date"] = "Thu, 06 Mar 2025 12:00:00 +0000" + msg["Message-ID"] = msg_id or f"<{uuid.uuid4()}@example.com>" + msg.attach(MIMEText(body, "plain")) + msg.attach(MIMEText(f"

{body}

", "html")) + return msg.as_bytes() + + +class MockMessagesAPI: + """Mock Messages API server for testing the client-bridge.""" + + def __init__(self): + self.app = FastAPI() + self.channels: dict[str, dict] = {} + self.mailbox_threads: dict[str, list[dict]] = {} + self.thread_messages: dict[str, list[dict]] = {} + self.message_emls: dict[str, bytes] = {} + self.submitted_messages: list[dict] = [] + self.message_flag_updates: list[dict] = [] + self.server = None + + @self.app.post("/api/v1.0/client-bridge/auth/") + async def client_bridge_auth(request: Request): + data = await request.json() + username = data.get("username") + password = data.get("password") + # Find a channel matching this email and password + for channel_id, channel in self.channels.items(): + if channel.get("mailbox_email") != username: + continue + expected = (channel.get("settings") or {}).get("password") + if password == expected: + expiry = channel.get("token_expiry", 3600) + token = pyjwt.encode( + { + "channel_id": channel_id, + "mailbox_id": channel["mailbox_id"], + "role": channel.get("role", "sender"), + "exp": datetime.now(timezone.utc) + timedelta(seconds=expiry), + }, + TEST_API_SECRET, + algorithm="HS256", + ) + return {"token": token} + return JSONResponse(status_code=401, content={"detail": "Invalid credentials"}) + + @self.app.post("/api/v1.0/client-bridge/submit/") + async def client_bridge_submit(request: Request): + channel_token = request.headers.get("x-channel-token") + mail_from = request.headers.get("x-mail-from") + rcpt_to = request.headers.get("x-rcpt-to") + body = await request.body() + + self.submitted_messages.append( + { + "channel_token": channel_token, + "mail_from": mail_from, + "rcpt_to": rcpt_to, + "raw_message": body, + } + ) + + return JSONResponse( + status_code=202, + content={ + "message_id": str(uuid.uuid4()), + "status": "accepted", + }, + ) + + @self.app.get("/api/v1.0/threads/") + async def list_threads(request: Request): + mailbox_id = request.query_params.get("mailbox_id") + # Determine folder from query params + folder = "inbox" + if request.query_params.get("has_trashed") == "1": + folder = "trash" + elif request.query_params.get("has_draft") == "1": + folder = "drafts" + elif request.query_params.get("is_spam") == "1": + folder = "spam" + elif request.query_params.get("has_archived") == "1": + folder = "archive" + elif request.query_params.get("has_sender") == "1": + folder = "sent" + elif request.query_params.get("has_starred") == "1": + folder = "starred" + + key = f"{mailbox_id}:{folder}" + threads = self.mailbox_threads.get(key, []) + return {"count": len(threads), "results": threads} + + @self.app.get("/api/v1.0/messages/") + async def list_messages(request: Request): + thread_id = request.query_params.get("thread_id") + messages = self.thread_messages.get(thread_id, []) + return {"count": len(messages), "results": messages} + + @self.app.get("/api/v1.0/messages/{message_id}/eml/") + async def get_eml(message_id: str): + eml = self.message_emls.get(message_id) + if not eml: + return JSONResponse(status_code=404, content={"detail": "Not found"}) + return Response( + content=eml, + media_type="message/rfc822", + headers={"Content-Disposition": f'attachment; filename="{message_id}.eml"'}, + ) + + @self.app.post("/api/v1.0/flag/") + async def change_flag(request: Request): + data = await request.json() + self.message_flag_updates.append(data) + return {"success": True, "updated_threads": 1} + + @self.app.get("/health") + async def health_check(): + return {"status": "healthy"} + + def add_channel( + self, + channel_id: str, + mailbox_id: str, + password: str, + mailbox_email: str = "test@example.com", + token_expiry: int = 3600, + role: str = "sender", + ): + """Register a channel for authentication.""" + self.channels[channel_id] = { + "mailbox_id": mailbox_id, + "mailbox_email": mailbox_email, + "settings": {"password": password}, + "token_expiry": token_expiry, + "role": role, + } + + def add_thread(self, mailbox_id: str, folder: str, thread: dict): + """Add a thread to a mailbox folder.""" + key = f"{mailbox_id}:{folder}" + self.mailbox_threads.setdefault(key, []).append(thread) + + def add_message(self, thread_id: str, message: dict, eml: bytes | None = None): + """Add a message to a thread with optional EML content.""" + self.thread_messages.setdefault(thread_id, []).append(message) + if eml is not None: + self.message_emls[message["id"]] = eml + + def start(self): + self.server = uvicorn.Server( + uvicorn.Config( + self.app, + host="0.0.0.0", + port=MOCK_API_PORT, + log_level="info", + loop="asyncio", + reload=False, + ) + ) + self.thread = threading.Thread(target=self.server.run, daemon=True) + self.thread.start() + # Wait for server to be ready + for _ in range(50): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect(("localhost", MOCK_API_PORT)) + break + except (ConnectionRefusedError, OSError): + time.sleep(0.1) + else: + raise RuntimeError(f"Mock API server did not become ready on port {MOCK_API_PORT}") + + def stop(self): + if self.server: + self.server.should_exit = True + self.thread.join(timeout=10) + + +class IMAPServer: + """Manages a pymap IMAP server for testing.""" + + def __init__(self, api_url: str, api_secret: str = ""): + self.api_url = api_url + self.api_secret = api_secret + self._process = None + self._loop = None + self._thread = None + + def start(self): + """Start the pymap server in a background thread.""" + + def run(): + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.run_until_complete(self._run_server()) + + self._thread = threading.Thread(target=run, daemon=True) + self._thread.start() + + # Wait for IMAP port to be ready + for _ in range(100): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(1) + s.connect((IMAP_HOST, IMAP_PORT)) + logger.info("IMAP server ready on %s:%d", IMAP_HOST, IMAP_PORT) + return + except (ConnectionRefusedError, OSError, socket.timeout): + time.sleep(0.1) + raise RuntimeError("IMAP server failed to start") + + async def _run_server(self): + from argparse import Namespace + + from pymap.backend import backends + from pymap.service import services + + # Register our backend + from src.backend import MessagesBackend + + backends.add("messages-api", MessagesBackend) + + args = Namespace( + host=IMAP_HOST, + port=IMAP_PORT, + debug=True, + cert=None, + key=None, + tls=False, + passlib_cfg=None, + proxy_protocol=None, + inherited_sockets=None, + api_url=self.api_url, + api_secret=self.api_secret, + backend="messages-api", + ) + + from contextlib import AsyncExitStack + + backend, config = await MessagesBackend.init(args) + config.apply_context() + + service_types = list(services.values()) + svc_instances = [svc_type(backend, config) for svc_type in service_types] + + async with AsyncExitStack() as stack: + await backend.start(stack) + for service in svc_instances: + await service.start(stack) + # Run forever (until thread is killed) + try: + while True: + await asyncio.sleep(1) + except asyncio.CancelledError: + pass + + def stop(self): + if self._loop: + for task in asyncio.all_tasks(self._loop): + self._loop.call_soon_threadsafe(task.cancel) + self._loop.call_soon_threadsafe(self._loop.stop) + if self._thread: + self._thread.join(timeout=5) + + +class SMTPServer: + """Manages an aiosmtpd SMTP server for testing.""" + + def __init__(self, api_url: str, api_secret: str = ""): + self.api_url = api_url + self.api_secret = api_secret + self._controller = None + + def start(self): + """Start the SMTP submission server in a background thread.""" + from aiosmtpd.controller import Controller + + from src.api.client import MessagesAPIClient + from src.submission import SubmissionAuthenticator, SubmissionHandler + + api_client = MessagesAPIClient(self.api_url, api_secret=self.api_secret) + handler = SubmissionHandler(api_client) + authenticator = SubmissionAuthenticator(api_client) + + self._controller = Controller( + handler, + hostname=SMTP_HOST, + port=SMTP_PORT, + authenticator=authenticator, + auth_require_tls=False, + auth_required=True, + ) + self._controller.start() + + # Wait for SMTP port to be ready + for _ in range(100): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(1) + s.connect((SMTP_HOST, SMTP_PORT)) + logger.info("SMTP server ready on %s:%d", SMTP_HOST, SMTP_PORT) + return + except (ConnectionRefusedError, OSError, socket.timeout): + time.sleep(0.1) + raise RuntimeError("SMTP server failed to start") + + def stop(self): + if self._controller: + self._controller.stop() + + +# --- Fixtures --- + + +@pytest.fixture(scope="session") +def mock_api(): + """Session-scoped mock API server.""" + server = MockMessagesAPI() + server.start() + yield server + server.stop() + + +@pytest.fixture(scope="session") +def imap_server(mock_api): + """Session-scoped IMAP server connected to the mock API.""" + server = IMAPServer(f"http://localhost:{MOCK_API_PORT}/api/v1.0/", api_secret=TEST_API_SECRET) + server.start() + yield server + server.stop() + + +@pytest.fixture(scope="session") +def smtp_server(mock_api): + """Session-scoped SMTP server connected to the mock API.""" + server = SMTPServer(f"http://localhost:{MOCK_API_PORT}/api/v1.0/", api_secret=TEST_API_SECRET) + server.start() + yield server + server.stop() + + +@pytest.fixture(scope="session") +def test_channel(mock_api): + """A test channel with messages set up in the mock API.""" + channel_id = "00000000-0000-4000-a000-000000000001" + mailbox_id = "00000000-0000-4000-a000-000000000002" + mailbox_email = "test@example.com" + password = "test-app-password-123" + + mock_api.add_channel(channel_id, mailbox_id, password, mailbox_email=mailbox_email) + + # Create threads and messages for INBOX + thread1_id = str(uuid.uuid4()) + msg1_id = str(uuid.uuid4()) + msg1_eml = _make_sample_eml( + subject="Welcome to Messages", + sender="admin@example.com", + to="test@example.com", + body="Welcome! This is your first message.", + ) + mock_api.add_thread( + mailbox_id, + "inbox", + { + "id": thread1_id, + "subject": "Welcome to Messages", + "snippet": "Welcome! This is your first message.", + "messaged_at": "2025-03-06T12:00:00Z", + }, + ) + mock_api.add_message( + thread1_id, + { + "id": msg1_id, + "thread_id": thread1_id, + "subject": "Welcome to Messages", + "is_unread": True, + "is_starred": False, + "is_trashed": False, + "sent_at": "2025-03-06T12:00:00Z", + "created_at": "2025-03-06T12:00:00Z", + }, + msg1_eml, + ) + + # Second thread with a read + starred message + thread2_id = str(uuid.uuid4()) + msg2_id = str(uuid.uuid4()) + msg2_eml = _make_sample_eml( + subject="Important Update", + sender="boss@example.com", + to="test@example.com", + body="Please review the attached document.", + ) + mock_api.add_thread( + mailbox_id, + "inbox", + { + "id": thread2_id, + "subject": "Important Update", + "snippet": "Please review the attached document.", + "messaged_at": "2025-03-06T13:00:00Z", + }, + ) + mock_api.add_message( + thread2_id, + { + "id": msg2_id, + "thread_id": thread2_id, + "subject": "Important Update", + "is_unread": False, + "is_starred": True, + "is_trashed": False, + "sent_at": "2025-03-06T13:00:00Z", + "created_at": "2025-03-06T13:00:00Z", + }, + msg2_eml, + ) + + # Add a message to Sent folder + thread3_id = str(uuid.uuid4()) + msg3_id = str(uuid.uuid4()) + msg3_eml = _make_sample_eml( + subject="Re: Project Update", + sender="test@example.com", + to="colleague@example.com", + body="Here is the project update you requested.", + ) + mock_api.add_thread( + mailbox_id, + "sent", + { + "id": thread3_id, + "subject": "Re: Project Update", + "snippet": "Here is the project update you requested.", + "messaged_at": "2025-03-06T14:00:00Z", + }, + ) + mock_api.add_message( + thread3_id, + { + "id": msg3_id, + "thread_id": thread3_id, + "subject": "Re: Project Update", + "is_unread": False, + "is_starred": False, + "is_trashed": False, + "is_sender": True, + "sent_at": "2025-03-06T14:00:00Z", + "created_at": "2025-03-06T14:00:00Z", + }, + msg3_eml, + ) + + # Add a message to Trash + thread4_id = str(uuid.uuid4()) + msg4_id = str(uuid.uuid4()) + msg4_eml = _make_sample_eml( + subject="Old Newsletter", + sender="news@example.com", + to="test@example.com", + body="This is an old newsletter.", + ) + mock_api.add_thread( + mailbox_id, + "trash", + { + "id": thread4_id, + "subject": "Old Newsletter", + "snippet": "This is an old newsletter.", + "messaged_at": "2025-03-05T10:00:00Z", + }, + ) + mock_api.add_message( + thread4_id, + { + "id": msg4_id, + "thread_id": thread4_id, + "subject": "Old Newsletter", + "is_unread": True, + "is_starred": False, + "is_trashed": True, + "sent_at": "2025-03-05T10:00:00Z", + "created_at": "2025-03-05T10:00:00Z", + }, + msg4_eml, + ) + + return { + "channel_id": channel_id, + "mailbox_id": mailbox_id, + "mailbox_email": mailbox_email, + "password": password, + "inbox_message_count": 2, + "sent_message_count": 1, + "trash_message_count": 1, + } + + +@pytest.fixture +def imap_client(imap_server, test_channel): + """An authenticated IMAP client connected to the test server.""" + client = imaplib.IMAP4(IMAP_HOST, IMAP_PORT) + client.login(test_channel["mailbox_email"], test_channel["password"]) + yield client + try: + client.logout() + except Exception: + pass + + +@pytest.fixture +def imap_connection(imap_server): + """An unauthenticated IMAP connection.""" + client = imaplib.IMAP4(IMAP_HOST, IMAP_PORT) + yield client + try: + client.logout() + except Exception: + pass + + +@pytest.fixture +def smtp_client(smtp_server, test_channel): + """An authenticated SMTP client connected to the test server.""" + client = smtplib.SMTP(SMTP_HOST, SMTP_PORT) + client.ehlo() + client.login(test_channel["mailbox_email"], test_channel["password"]) + yield client + try: + client.quit() + except Exception: + pass + + +@pytest.fixture +def smtp_connection(smtp_server): + """An unauthenticated SMTP connection.""" + client = smtplib.SMTP(SMTP_HOST, SMTP_PORT) + client.ehlo() + yield client + try: + client.quit() + except Exception: + pass + + +# Token expires in 2 seconds — short enough for fast tests +EXPIRY_SECONDS = 2 + + +@pytest.fixture(scope="session") +def expiring_channel(mock_api): + """A test channel whose session tokens expire in EXPIRY_SECONDS.""" + channel_id = "00000000-0000-4000-a000-000000000099" + mailbox_id = "00000000-0000-4000-a000-000000000098" + mailbox_email = "expiring@example.com" + password = "expiring-password" + + mock_api.add_channel( + channel_id, + mailbox_id, + password, + mailbox_email=mailbox_email, + token_expiry=EXPIRY_SECONDS, + ) + + # Add a thread so SELECT INBOX triggers an API call + thread_id = str(uuid.uuid4()) + msg_id = str(uuid.uuid4()) + mock_api.add_thread( + mailbox_id, + "inbox", + { + "id": thread_id, + "subject": "Expiry test", + "messaged_at": "2025-03-06T12:00:00Z", + }, + ) + mock_api.add_message( + thread_id, + { + "id": msg_id, + "thread_id": thread_id, + "subject": "Expiry test", + "is_unread": True, + "is_starred": False, + "is_trashed": False, + "sent_at": "2025-03-06T12:00:00Z", + "created_at": "2025-03-06T12:00:00Z", + }, + ) + + return { + "channel_id": channel_id, + "mailbox_id": mailbox_id, + "mailbox_email": mailbox_email, + "password": password, + } diff --git a/src/client-bridge/tests/test_imap_auth.py b/src/client-bridge/tests/test_imap_auth.py new file mode 100644 index 000000000..ca24d8567 --- /dev/null +++ b/src/client-bridge/tests/test_imap_auth.py @@ -0,0 +1,67 @@ +"""Tests for IMAP authentication using channel app-specific passwords.""" + +import imaplib + +import pytest + +from .conftest import IMAP_HOST, IMAP_PORT + + +def test_login_success(imap_connection, test_channel): + """Test successful IMAP LOGIN with valid channel credentials.""" + status, data = imap_connection.login(test_channel["mailbox_email"], test_channel["password"]) + assert status == "OK" + + +def test_login_wrong_password(imap_server, test_channel): + """Test IMAP LOGIN fails with wrong password.""" + client = imaplib.IMAP4(IMAP_HOST, IMAP_PORT) + with pytest.raises(imaplib.IMAP4.error): + client.login(test_channel["mailbox_email"], "wrong-password") + try: + client.logout() + except Exception: + pass + + +def test_login_nonexistent_mailbox(imap_server): + """Test IMAP LOGIN fails with nonexistent email address.""" + client = imaplib.IMAP4(IMAP_HOST, IMAP_PORT) + with pytest.raises(imaplib.IMAP4.error): + client.login("nobody@nonexistent.example", "some-password") + try: + client.logout() + except Exception: + pass + + +def test_login_empty_password(imap_server, test_channel): + """Test IMAP LOGIN fails with empty password.""" + client = imaplib.IMAP4(IMAP_HOST, IMAP_PORT) + with pytest.raises(imaplib.IMAP4.error): + client.login(test_channel["mailbox_email"], "") + try: + client.logout() + except Exception: + pass + + +def test_capability_before_login(imap_connection): + """Test CAPABILITY command before authentication.""" + status, caps = imap_connection.capability() + assert status == "OK" + # Should include IMAP4rev1 + cap_str = b" ".join(caps[0].split() if caps[0] else []).upper() + assert b"IMAP4REV1" in cap_str + + +def test_logout(imap_client): + """Test LOGOUT command.""" + status, data = imap_client.logout() + assert status == "BYE" + + +def test_noop_after_login(imap_client): + """Test NOOP command after authentication.""" + status, data = imap_client.noop() + assert status == "OK" diff --git a/src/client-bridge/tests/test_imap_folders.py b/src/client-bridge/tests/test_imap_folders.py new file mode 100644 index 000000000..0bc8cb7d7 --- /dev/null +++ b/src/client-bridge/tests/test_imap_folders.py @@ -0,0 +1,103 @@ +"""Tests for IMAP folder operations.""" + +import re + + +def test_list_all_folders(imap_client): + """Test LIST command returns all virtual folders.""" + status, data = imap_client.list() + assert status == "OK" + + folder_names = [] + for item in data: + if item: + decoded = item.decode() if isinstance(item, bytes) else str(item) + # Parse LIST response: (\\flags) "/" "FolderName" or (\\flags) "/" FolderName + # Extract the last quoted string or last word + match = re.search(r'"([^"]+)"$', decoded) + if match: + folder_names.append(match.group(1)) + else: + folder_names.append(decoded.split()[-1]) + + # Check that our virtual folders are present + assert any("INBOX" in f for f in folder_names), f"INBOX not found in {folder_names}" + + +def test_list_with_wildcard(imap_client): + """Test LIST with wildcard pattern.""" + status, data = imap_client.list('""', "*") + assert status == "OK" + assert len(data) > 0 + + +def test_list_inbox_only(imap_client): + """Test LIST with specific pattern for INBOX.""" + status, data = imap_client.list('""', "INBOX") + assert status == "OK" + assert len(data) >= 1 + + +def test_select_inbox(imap_client): + """Test SELECT INBOX.""" + status, data = imap_client.select("INBOX") + assert status == "OK" + # data[0] should be the message count + assert int(data[0]) >= 0 + + +def test_select_sent(imap_client): + """Test SELECT Sent folder.""" + status, data = imap_client.select("Sent") + assert status == "OK" + assert int(data[0]) >= 0 + + +def test_select_trash(imap_client): + """Test SELECT Trash folder.""" + status, data = imap_client.select("Trash") + assert status == "OK" + + +def test_select_nonexistent_folder(imap_client): + """Test SELECT for a folder that doesn't exist.""" + status, data = imap_client.select("NonExistentFolder") + assert status == "NO" + + +def test_examine_inbox(imap_client): + """Test EXAMINE (read-only SELECT) on INBOX.""" + status, data = imap_client.select("INBOX", readonly=True) + assert status == "OK" + assert int(data[0]) >= 0 + + +def test_status_inbox(imap_client): + """Test STATUS command on INBOX.""" + status, data = imap_client.status("INBOX", "(MESSAGES UNSEEN UIDNEXT UIDVALIDITY)") + assert status == "OK" + assert data[0] is not None + + +def test_lsub_all(imap_client): + """Test LSUB (list subscribed folders).""" + status, data = imap_client.lsub() + assert status == "OK" + + +def test_create_folder_rejected(imap_client): + """Test CREATE is rejected for API-backed mailboxes.""" + status, data = imap_client.create("NewFolder") + assert status == "NO" + + +def test_delete_folder_rejected(imap_client): + """Test DELETE is rejected for API-backed mailboxes.""" + status, data = imap_client.delete("Sent") + assert status == "NO" + + +def test_rename_folder_rejected(imap_client): + """Test RENAME is rejected for API-backed mailboxes.""" + status, data = imap_client.rename("Sent", "SentMail") + assert status == "NO" diff --git a/src/client-bridge/tests/test_imap_messages.py b/src/client-bridge/tests/test_imap_messages.py new file mode 100644 index 000000000..6e4834453 --- /dev/null +++ b/src/client-bridge/tests/test_imap_messages.py @@ -0,0 +1,234 @@ +"""Tests for IMAP message operations using Python's imaplib.""" + +import email + + +def test_fetch_message_count(imap_client, test_channel): + """Test that INBOX has the expected number of messages.""" + status, data = imap_client.select("INBOX") + assert status == "OK" + count = int(data[0]) + assert count == test_channel["inbox_message_count"] + + +def test_fetch_all_messages(imap_client, test_channel): + """Test FETCH all messages in INBOX.""" + imap_client.select("INBOX") + status, data = imap_client.search(None, "ALL") + assert status == "OK" + + message_nums = data[0].split() + assert len(message_nums) == test_channel["inbox_message_count"] + + +def test_fetch_message_envelope(imap_client): + """Test FETCH message envelope (headers).""" + imap_client.select("INBOX") + status, data = imap_client.fetch("1", "(ENVELOPE)") + assert status == "OK" + assert data[0] is not None + + +def test_fetch_message_body(imap_client): + """Test FETCH full message body.""" + imap_client.select("INBOX") + status, data = imap_client.fetch("1", "(BODY[])") + assert status == "OK" + + # Parse the raw email + raw_email = data[0][1] + msg = email.message_from_bytes(raw_email) + assert msg["Subject"] == "Welcome to Messages" + assert "sender" in msg["From"] or "admin" in msg["From"] + + +def test_fetch_message_headers_only(imap_client): + """Test FETCH message headers only.""" + imap_client.select("INBOX") + status, data = imap_client.fetch("1", "(BODY[HEADER])") + assert status == "OK" + raw = data[0][1] + assert b"Subject:" in raw + + +def test_fetch_specific_headers(imap_client): + """Test FETCH specific headers (Subject, From, To).""" + imap_client.select("INBOX") + status, data = imap_client.fetch("1", "(BODY[HEADER.FIELDS (SUBJECT FROM TO)])") + assert status == "OK" + raw = data[0][1] + assert b"Subject:" in raw or b"From:" in raw + + +def test_fetch_message_text(imap_client): + """Test FETCH message text body.""" + imap_client.select("INBOX") + status, data = imap_client.fetch("1", "(BODY[TEXT])") + assert status == "OK" + assert len(data[0][1]) > 0 + + +def test_fetch_flags(imap_client): + """Test FETCH flags reflects API read state (webmail → IMAP). + + Message 1 has is_unread=True in the API → should NOT have \\Seen. + Message 2 has is_unread=False in the API → should have \\Seen. + """ + imap_client.select("INBOX") + + # Message 1: is_unread=True → no \Seen flag + status, data = imap_client.fetch("1", "(FLAGS)") + assert status == "OK" + flags_str = data[0].decode() if isinstance(data[0], bytes) else str(data[0]) + assert "\\Seen" not in flags_str, "Unread message should not have \\Seen" + + # Message 2: is_unread=False → \Seen flag present + status, data = imap_client.fetch("2", "(FLAGS)") + assert status == "OK" + flags_str = data[0].decode() if isinstance(data[0], bytes) else str(data[0]) + assert "\\Seen" in flags_str, "Read message should have \\Seen" + + +def test_fetch_uid(imap_client): + """Test UID FETCH command.""" + imap_client.select("INBOX") + # First get UIDs + status, data = imap_client.uid("SEARCH", None, "ALL") + assert status == "OK" + uids = data[0].split() + assert len(uids) > 0 + + # Fetch by UID + uid = uids[0] + status, data = imap_client.uid("FETCH", uid, "(BODY[])") + assert status == "OK" + + +def test_fetch_rfc822(imap_client): + """Test FETCH RFC822 (full message).""" + imap_client.select("INBOX") + status, data = imap_client.fetch("1", "(RFC822)") + assert status == "OK" + raw = data[0][1] + msg = email.message_from_bytes(raw) + assert msg["Subject"] is not None + + +def test_fetch_rfc822_size(imap_client): + """Test FETCH RFC822.SIZE.""" + imap_client.select("INBOX") + status, data = imap_client.fetch("1", "(RFC822.SIZE)") + assert status == "OK" + assert data[0] is not None + + +def test_fetch_bodystructure(imap_client): + """Test FETCH BODYSTRUCTURE.""" + imap_client.select("INBOX") + status, data = imap_client.fetch("1", "(BODYSTRUCTURE)") + assert status == "OK" + assert data[0] is not None + + +def test_fetch_internaldate(imap_client): + """Test FETCH INTERNALDATE.""" + imap_client.select("INBOX") + status, data = imap_client.fetch("1", "(INTERNALDATE)") + assert status == "OK" + assert data[0] is not None + + +def test_search_all(imap_client, test_channel): + """Test SEARCH ALL messages.""" + imap_client.select("INBOX") + status, data = imap_client.search(None, "ALL") + assert status == "OK" + nums = data[0].split() + assert len(nums) == test_channel["inbox_message_count"] + + +def test_search_unseen(imap_client): + """Test SEARCH UNSEEN messages.""" + imap_client.select("INBOX") + status, data = imap_client.search(None, "UNSEEN") + assert status == "OK" + # At least one unseen message (the first one) + nums = data[0].split() + assert len(nums) >= 1 + + +def test_search_seen(imap_client): + """Test SEARCH SEEN messages.""" + imap_client.select("INBOX") + status, data = imap_client.search(None, "SEEN") + assert status == "OK" + # The second message is read + nums = data[0].split() + assert len(nums) >= 1 + + +def test_search_flagged(imap_client): + """Test SEARCH FLAGGED (starred) messages.""" + imap_client.select("INBOX") + status, data = imap_client.search(None, "FLAGGED") + assert status == "OK" + nums = data[0].split() + assert len(nums) >= 1 + + +def test_uid_search(imap_client): + """Test UID SEARCH command.""" + imap_client.select("INBOX") + status, data = imap_client.uid("SEARCH", None, "ALL") + assert status == "OK" + uids = data[0].split() + assert len(uids) > 0 + # UIDs should be numeric + for uid in uids: + assert uid.isdigit() + + +def test_sent_folder_messages(imap_client, test_channel): + """Test messages in the Sent folder.""" + status, data = imap_client.select("Sent") + assert status == "OK" + count = int(data[0]) + assert count == test_channel["sent_message_count"] + + +def test_trash_folder_messages(imap_client, test_channel): + """Test messages in the Trash folder.""" + status, data = imap_client.select("Trash") + assert status == "OK" + count = int(data[0]) + assert count == test_channel["trash_message_count"] + + +def test_fetch_multiple_messages(imap_client): + """Test FETCH multiple messages at once.""" + imap_client.select("INBOX") + status, data = imap_client.fetch("1:*", "(FLAGS ENVELOPE)") + assert status == "OK" + # Should have data for each message + assert len([d for d in data if d and d != b")"]) >= 1 + + +def test_close_mailbox(imap_client): + """Test CLOSE command after selecting a mailbox.""" + imap_client.select("INBOX") + status, data = imap_client.close() + assert status == "OK" + + +def test_noop_in_selected_state(imap_client): + """Test NOOP while a mailbox is selected.""" + imap_client.select("INBOX") + status, data = imap_client.noop() + assert status == "OK" + + +def test_check_command(imap_client): + """Test CHECK command.""" + imap_client.select("INBOX") + status, data = imap_client.check() + assert status == "OK" diff --git a/src/client-bridge/tests/test_imap_operations.py b/src/client-bridge/tests/test_imap_operations.py new file mode 100644 index 000000000..af22e34c9 --- /dev/null +++ b/src/client-bridge/tests/test_imap_operations.py @@ -0,0 +1,481 @@ +"""Tests for IMAP flag operations, COPY, MOVE, EXPUNGE, and APPEND. + +These tests were added after manual testing with multiple IMAP clients +(curl, mbsync, Perl Mail::IMAPClient, raw netcat, Python imaplib) revealed +that these operations had no unit test coverage. +""" + +import imaplib +from email.mime.text import MIMEText + +from tests.conftest import IMAP_HOST, IMAP_PORT + + +# --- STORE flag operations --- + + +def test_store_add_seen_flag(imap_client): + """Test STORE +FLAGS (\\Seen) marks a message as read.""" + imap_client.select("INBOX") + # Message 1 is unread initially - add \Seen flag + status, data = imap_client.store("1", "+FLAGS", "(\\Seen)") + assert status == "OK" + + # Verify the flag was applied + status, data = imap_client.fetch("1", "(FLAGS)") + assert status == "OK" + flags_str = data[0].decode() if isinstance(data[0], bytes) else str(data[0]) + assert "\\Seen" in flags_str + + # Cleanup: remove the flag we just set + imap_client.store("1", "-FLAGS", "(\\Seen)") + + +def test_store_remove_seen_flag(imap_client): + """Test STORE -FLAGS (\\Seen) marks a message as unread.""" + imap_client.select("INBOX") + # Message 2 is read (has \Seen) - remove it + status, data = imap_client.store("2", "-FLAGS", "(\\Seen)") + assert status == "OK" + + status, data = imap_client.fetch("2", "(FLAGS)") + assert status == "OK" + flags_str = data[0].decode() if isinstance(data[0], bytes) else str(data[0]) + assert "\\Seen" not in flags_str + + # Cleanup: restore the flag + imap_client.store("2", "+FLAGS", "(\\Seen)") + + +def test_store_seen_syncs_to_api(imap_client, mock_api): + """Test that IMAP \\Seen flag changes propagate to the API (IMAP → webmail). + + Adding \\Seen should call POST /flag/ with flag=unread, value=false. + Removing \\Seen should call POST /flag/ with flag=unread, value=true. + """ + imap_client.select("INBOX") + + # Clear previous flag updates + mock_api.message_flag_updates.clear() + + # Mark as read: +FLAGS (\Seen) → API should receive flag=unread, value=False + imap_client.store("1", "+FLAGS", "(\\Seen)") + unread_updates = [u for u in mock_api.message_flag_updates if u.get("flag") == "unread"] + assert any(u.get("value") is False for u in unread_updates), ( + f"Expected flag=unread, value=False in API updates: {mock_api.message_flag_updates}" + ) + + mock_api.message_flag_updates.clear() + + # Mark as unread: -FLAGS (\Seen) → API should receive flag=unread, value=True + imap_client.store("1", "-FLAGS", "(\\Seen)") + unread_updates = [u for u in mock_api.message_flag_updates if u.get("flag") == "unread"] + assert any(u.get("value") is True for u in unread_updates), ( + f"Expected flag=unread, value=True in API updates: {mock_api.message_flag_updates}" + ) + + +def test_store_add_flagged(imap_client): + """Test STORE +FLAGS (\\Flagged) stars a message.""" + imap_client.select("INBOX") + # Message 1 is not starred - add \Flagged + status, data = imap_client.store("1", "+FLAGS", "(\\Flagged)") + assert status == "OK" + + status, data = imap_client.fetch("1", "(FLAGS)") + assert status == "OK" + flags_str = data[0].decode() if isinstance(data[0], bytes) else str(data[0]) + assert "\\Flagged" in flags_str + + # Cleanup + imap_client.store("1", "-FLAGS", "(\\Flagged)") + + +def test_store_add_deleted_flag(imap_client): + """Test STORE +FLAGS (\\Deleted) marks a message for deletion.""" + imap_client.select("INBOX") + status, data = imap_client.store("1", "+FLAGS", "(\\Deleted)") + assert status == "OK" + + status, data = imap_client.fetch("1", "(FLAGS)") + assert status == "OK" + flags_str = data[0].decode() if isinstance(data[0], bytes) else str(data[0]) + assert "\\Deleted" in flags_str + + # Cleanup: remove the flag before any expunge + imap_client.store("1", "-FLAGS", "(\\Deleted)") + + +def test_store_replace_flags(imap_client): + """Test STORE FLAGS (replace all flags). + + This was found problematic with Perl's Mail::IMAPClient during manual + testing (BAD UID STORE: Invalid arguments), but raw IMAP worked fine. + Verifies the server correctly handles flag replacement via imaplib. + """ + imap_client.select("INBOX") + + # Replace all flags with just \Seen and \Flagged + status, data = imap_client.store("1", "FLAGS", "(\\Seen \\Flagged)") + assert status == "OK" + + status, data = imap_client.fetch("1", "(FLAGS)") + assert status == "OK" + flags_str = data[0].decode() if isinstance(data[0], bytes) else str(data[0]) + assert "\\Seen" in flags_str + assert "\\Flagged" in flags_str + + # Cleanup: restore original flags (message 1 was unread, not starred) + imap_client.store("1", "FLAGS", "()") + + +def test_store_multiple_flags_at_once(imap_client): + """Test STORE +FLAGS with multiple flags in one command.""" + imap_client.select("INBOX") + status, data = imap_client.store("1", "+FLAGS", "(\\Seen \\Flagged)") + assert status == "OK" + + status, data = imap_client.fetch("1", "(FLAGS)") + assert status == "OK" + flags_str = data[0].decode() if isinstance(data[0], bytes) else str(data[0]) + assert "\\Seen" in flags_str + assert "\\Flagged" in flags_str + + # Cleanup + imap_client.store("1", "-FLAGS", "(\\Seen \\Flagged)") + + +def test_uid_store_flags(imap_client): + """Test UID STORE for flag operations.""" + imap_client.select("INBOX") + + # Get UID of first message + status, data = imap_client.uid("SEARCH", None, "ALL") + assert status == "OK" + uid = data[0].split()[0] + + # UID STORE +FLAGS + status, data = imap_client.uid("STORE", uid, "+FLAGS", "(\\Seen)") + assert status == "OK" + + # Verify via UID FETCH + status, data = imap_client.uid("FETCH", uid, "(FLAGS)") + assert status == "OK" + flags_str = data[0].decode() if isinstance(data[0], bytes) else str(data[0]) + assert "\\Seen" in flags_str + + # Cleanup + imap_client.uid("STORE", uid, "-FLAGS", "(\\Seen)") + + +def test_store_silent_flag(imap_client): + """Test STORE +FLAGS.SILENT (should not return updated flags).""" + imap_client.select("INBOX") + status, data = imap_client.store("1", "+FLAGS.SILENT", "(\\Seen)") + assert status == "OK" + # SILENT mode should not include FETCH response with flags + # The response should be minimal + + # Cleanup + imap_client.store("1", "-FLAGS", "(\\Seen)") + + +# --- SEARCH after flag changes --- + + +def test_search_after_store(imap_client): + """Test that SEARCH reflects flag changes made by STORE.""" + imap_client.select("INBOX") + + # Mark message 1 as Seen + imap_client.store("1", "+FLAGS", "(\\Seen)") + + # Search for SEEN should now include message 1 + status, data = imap_client.search(None, "SEEN") + assert status == "OK" + seen_msgs = data[0].split() + assert b"1" in seen_msgs + + # Search for UNSEEN should NOT include message 1 + status, data = imap_client.search(None, "UNSEEN") + assert status == "OK" + unseen_msgs = data[0].split() + assert b"1" not in unseen_msgs + + # Cleanup + imap_client.store("1", "-FLAGS", "(\\Seen)") + + +# --- EXPUNGE --- + + +def test_expunge_deleted_message(imap_client): + """Test EXPUNGE removes messages marked with \\Deleted.""" + imap_client.select("INBOX") + + # Get initial count + status, data = imap_client.search(None, "ALL") + assert status == "OK" + initial_count = len(data[0].split()) + + # Mark message 1 as deleted + status, data = imap_client.store("1", "+FLAGS", "(\\Deleted)") + assert status == "OK" + + # Expunge + status, data = imap_client.expunge() + assert status == "OK" + + # Message count should be reduced + status, data = imap_client.search(None, "ALL") + assert status == "OK" + new_count = len(data[0].split()) if data[0] else 0 + assert new_count == initial_count - 1 + + +def test_expunge_without_deleted(imap_client): + """Test EXPUNGE with no deleted messages is a no-op.""" + imap_client.select("Sent") + status, data = imap_client.search(None, "ALL") + assert status == "OK" + initial_count = len(data[0].split()) if data[0] else 0 + + status, data = imap_client.expunge() + assert status == "OK" + + status, data = imap_client.search(None, "ALL") + assert status == "OK" + final_count = len(data[0].split()) if data[0] else 0 + assert final_count == initial_count + + +# --- COPY --- + + +def test_copy_message_to_trash(imap_client): + """Test COPY message from Sent to Trash. + + COPY was found problematic with Perl's Mail::IMAPClient during manual + testing (BAD UID COPY: Invalid arguments), but raw IMAP and imaplib + worked correctly. This test verifies proper COPY behavior. + """ + imap_client.select("Sent") + status, data = imap_client.search(None, "ALL") + assert status == "OK" + assert data[0], "Sent folder should have messages" + + # Get initial Trash count + imap_client.select("Trash") + status, data = imap_client.search(None, "ALL") + initial_trash = len(data[0].split()) if data[0] else 0 + + # Copy message 1 from Sent to Trash + imap_client.select("Sent") + status, data = imap_client.copy("1", "Trash") + assert status == "OK" + + # Verify the message exists in Trash + imap_client.select("Trash") + status, data = imap_client.search(None, "ALL") + assert status == "OK" + new_trash = len(data[0].split()) if data[0] else 0 + assert new_trash == initial_trash + 1 + + +def test_uid_copy(imap_client): + """Test UID COPY command.""" + imap_client.select("Sent") + status, data = imap_client.uid("SEARCH", None, "ALL") + assert status == "OK" + uid = data[0].split()[0] + + status, data = imap_client.uid("COPY", uid, "Drafts") + assert status == "OK" + + +def test_copy_to_nonexistent_folder(imap_client): + """Test COPY to a non-existent folder fails gracefully.""" + imap_client.select("Sent") + try: + status, data = imap_client.copy("1", "NonExistent") + assert status == "NO" + except imaplib.IMAP4.error: + # imaplib raises on NO/BAD responses + pass + + +# --- APPEND --- + + +def test_append_rejected(imap_client): + """Test APPEND is rejected for API-backed mailboxes.""" + msg = MIMEText("This is a test draft message.") + msg["Subject"] = "Test Draft" + msg["From"] = "test@example.com" + msg["To"] = "recipient@example.com" + raw_msg = msg.as_bytes() + + # APPEND should be rejected since the Messages API does not support it + try: + status, data = imap_client.append("Drafts", "(\\Seen)", None, raw_msg) + assert status == "NO", "APPEND should be rejected for API-backed mailboxes" + except imaplib.IMAP4.error: + # imaplib raises on NO/BAD responses — this is expected + pass + + +# --- SUBSCRIBE / UNSUBSCRIBE --- + + +def test_subscribe_unsubscribe(imap_client): + """Test SUBSCRIBE and UNSUBSCRIBE commands.""" + # Unsubscribe from Drafts + status, data = imap_client.unsubscribe("Drafts") + assert status == "OK" + + # LSUB should not include Drafts + status, data = imap_client.lsub() + assert status == "OK" + folders = [] + for item in data: + if item: + decoded = item.decode() if isinstance(item, bytes) else str(item) + folders.append(decoded) + assert not any("Drafts" in f for f in folders), ( + f"Drafts should not be in LSUB after unsubscribe: {folders}" + ) + + # Re-subscribe + status, data = imap_client.subscribe("Drafts") + assert status == "OK" + + # LSUB should include Drafts again + status, data = imap_client.lsub() + assert status == "OK" + folders = [] + for item in data: + if item: + decoded = item.decode() if isinstance(item, bytes) else str(item) + folders.append(decoded) + assert any("Drafts" in f for f in folders), ( + f"Drafts should be in LSUB after resubscribe: {folders}" + ) + + +# --- CLOSE with implicit expunge --- + + +def test_close_expunges_deleted(imap_server, test_channel): + """Test that CLOSE implicitly expunges deleted messages. + + Uses a fresh connection to avoid affecting other tests. + """ + client = imaplib.IMAP4(IMAP_HOST, IMAP_PORT) + client.login(test_channel["mailbox_email"], test_channel["password"]) + + try: + client.select("Trash") + status, data = client.search(None, "ALL") + assert status == "OK" + if not data[0]: + # No messages in Trash, nothing to test + return + + initial_count = len(data[0].split()) + + # Mark first message as deleted + client.store("1", "+FLAGS", "(\\Deleted)") + + # CLOSE should expunge deleted messages + client.close() + + # Re-select Trash to check + client.select("Trash") + status, data = client.search(None, "ALL") + assert status == "OK" + new_count = len(data[0].split()) if data[0] else 0 + assert new_count == initial_count - 1 + finally: + try: + client.logout() + except Exception: + pass + + +# --- Multi-folder operations --- + + +def test_select_switch_folders(imap_client, test_channel): + """Test switching between folders preserves correct message counts.""" + # Select INBOX + status, data = imap_client.select("INBOX") + assert status == "OK" + inbox_count = int(data[0]) + + # Switch to Sent + status, data = imap_client.select("Sent") + assert status == "OK" + sent_count = int(data[0]) + + # Switch back to INBOX + status, data = imap_client.select("INBOX") + assert status == "OK" + assert int(data[0]) == inbox_count + + # Counts should match fixture data + assert sent_count == test_channel["sent_message_count"] + + +# --- STATUS across folders --- + + +def test_status_multiple_folders(imap_client): + """Test STATUS command across multiple folders without SELECT.""" + for folder in ["INBOX", "Sent", "Trash", "Drafts"]: + status, data = imap_client.status(folder, "(MESSAGES UNSEEN)") + assert status == "OK", f"STATUS failed for {folder}" + # Parse the response to verify it contains MESSAGES and UNSEEN + response_str = data[0].decode() if isinstance(data[0], bytes) else str(data[0]) + assert "MESSAGES" in response_str, f"No MESSAGES in STATUS for {folder}" + assert "UNSEEN" in response_str, f"No UNSEEN in STATUS for {folder}" + + +# --- Edge cases --- + + +def test_fetch_nonexistent_message(imap_client): + """Test FETCH for a message number that doesn't exist.""" + imap_client.select("INBOX") + # Try to fetch message 999 + status, data = imap_client.fetch("999", "(FLAGS)") + # Should either return OK with empty data or NO + # pymap may return OK with empty results for non-existent messages + assert status in ("OK", "NO") + + +def test_store_on_readonly_mailbox(imap_client): + """Test that STORE fails on a mailbox opened with EXAMINE (readonly).""" + imap_client.select("INBOX", readonly=True) + try: + status, data = imap_client.store("1", "+FLAGS", "(\\Seen)") + assert status == "NO", "STORE should fail on readonly mailbox" + except imaplib.IMAP4.error: + # imaplib raises on NO/BAD responses - this is expected + pass + + +def test_copy_preserves_source(imap_client): + """Test that COPY does not remove the source message.""" + imap_client.select("Sent") + status, data = imap_client.search(None, "ALL") + assert status == "OK" + initial_count = len(data[0].split()) if data[0] else 0 + + # Copy message 1 to Archive + imap_client.copy("1", "Archive") + + # Source message should still exist + status, data = imap_client.search(None, "ALL") + assert status == "OK" + final_count = len(data[0].split()) if data[0] else 0 + assert final_count == initial_count, "COPY should not remove source message" diff --git a/src/client-bridge/tests/test_session_expiry.py b/src/client-bridge/tests/test_session_expiry.py new file mode 100644 index 000000000..74b501e57 --- /dev/null +++ b/src/client-bridge/tests/test_session_expiry.py @@ -0,0 +1,94 @@ +"""Tests for session token expiry in IMAP and SMTP paths. + +Uses the running IMAP/SMTP servers with a channel whose JWT expires +in ~2 seconds to verify clean disconnection on expiry. +""" + +import imaplib +import smtplib +import time +from email.mime.text import MIMEText + +import pytest + +from .conftest import EXPIRY_SECONDS, IMAP_HOST, IMAP_PORT, SMTP_HOST, SMTP_PORT + + +class TestIMAPSessionExpiry: + """Test that IMAP sessions are closed when the JWT token expires.""" + + def test_select_after_expiry(self, imap_server, expiring_channel): + """SELECT should fail after the session token expires.""" + client = imaplib.IMAP4(IMAP_HOST, IMAP_PORT) + client.login(expiring_channel["mailbox_email"], expiring_channel["password"]) + + # Wait for the JWT to expire + time.sleep(EXPIRY_SECONDS + 1) + + # SELECT triggers _load_messages() -> _check_token() -> SessionExpired -> CloseConnection + with pytest.raises((imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError)): + client.select("INBOX") + + def test_immediate_select_works(self, imap_server, expiring_channel): + """SELECT should succeed when the token is still valid.""" + client = imaplib.IMAP4(IMAP_HOST, IMAP_PORT) + client.login(expiring_channel["mailbox_email"], expiring_channel["password"]) + + # Immediately select — token is fresh + status, _ = client.select("INBOX") + assert status == "OK" + + try: + client.logout() + except Exception: + pass + + +class TestSMTPSessionExpiry: + """Test that SMTP handle_DATA checks token expiry.""" + + def test_send_after_expiry(self, smtp_server, expiring_channel): + """Sending after token expiry should fail with 421.""" + client = smtplib.SMTP(SMTP_HOST, SMTP_PORT) + client.ehlo() + client.login(expiring_channel["mailbox_email"], expiring_channel["password"]) + + # Wait for the JWT to expire + time.sleep(EXPIRY_SECONDS + 1) + + msg = MIMEText("This should fail - token expired.") + msg["Subject"] = "Expiry test" + msg["From"] = "expiring@example.com" + msg["To"] = "recipient@example.com" + + with pytest.raises(smtplib.SMTPResponseException) as exc_info: + client.sendmail( + "expiring@example.com", + ["recipient@example.com"], + msg.as_string(), + ) + assert exc_info.value.smtp_code == 421 + + def test_immediate_send_works(self, smtp_server, expiring_channel, mock_api): + """Sending immediately should succeed when the token is still valid.""" + client = smtplib.SMTP(SMTP_HOST, SMTP_PORT) + client.ehlo() + client.login(expiring_channel["mailbox_email"], expiring_channel["password"]) + + msg = MIMEText("This should succeed - token is fresh.") + msg["Subject"] = "No expiry test" + msg["From"] = "expiring@example.com" + msg["To"] = "recipient@example.com" + + initial_count = len(mock_api.submitted_messages) + client.sendmail( + "expiring@example.com", + ["recipient@example.com"], + msg.as_string(), + ) + assert len(mock_api.submitted_messages) == initial_count + 1 + + try: + client.quit() + except Exception: + pass diff --git a/src/client-bridge/tests/test_smtp_submission.py b/src/client-bridge/tests/test_smtp_submission.py new file mode 100644 index 000000000..acadce3ea --- /dev/null +++ b/src/client-bridge/tests/test_smtp_submission.py @@ -0,0 +1,93 @@ +"""Tests for SMTP submission server.""" + +import smtplib +from email.mime.text import MIMEText + +import pytest + +from .conftest import SMTP_HOST, SMTP_PORT + + +class TestSMTPAuth: + """Test SMTP authentication.""" + + def test_auth_success(self, smtp_client, test_channel): + """Authenticated client should be connected.""" + # smtp_client fixture already authenticates successfully + assert smtp_client.sock is not None + + def test_auth_wrong_password(self, smtp_connection, test_channel): + """Wrong password should be rejected.""" + with pytest.raises(smtplib.SMTPAuthenticationError): + smtp_connection.login(test_channel["mailbox_email"], "wrong-password") + + def test_auth_nonexistent_mailbox(self, smtp_connection): + """Non-existent email address should be rejected.""" + with pytest.raises(smtplib.SMTPAuthenticationError): + smtp_connection.login("nobody@nonexistent.example", "some-password") + + def test_auth_empty_password(self, smtp_connection, test_channel): + """Empty password should be rejected.""" + with pytest.raises(smtplib.SMTPAuthenticationError): + smtp_connection.login(test_channel["mailbox_email"], "") + + +class TestSMTPSendMessage: + """Test SMTP message submission.""" + + def test_send_message(self, smtp_client, test_channel, mock_api): + """Sending a message via SMTP should succeed.""" + msg = MIMEText("Hello, this is a test message.") + msg["Subject"] = "Test from SMTP" + msg["From"] = "test@example.com" + msg["To"] = "recipient@example.com" + + initial_count = len(mock_api.submitted_messages) + smtp_client.sendmail( + "test@example.com", + ["recipient@example.com"], + msg.as_string(), + ) + + assert len(mock_api.submitted_messages) == initial_count + 1 + submitted = mock_api.submitted_messages[-1] + assert submitted["channel_token"] # JWT token was forwarded + assert submitted["mail_from"] == "test@example.com" + assert "recipient@example.com" in submitted["rcpt_to"] + + def test_send_without_auth(self, smtp_server): + """Sending without authentication should fail.""" + client = smtplib.SMTP(SMTP_HOST, SMTP_PORT) + client.ehlo() + + msg = MIMEText("Unauthorized message.") + msg["Subject"] = "Should fail" + msg["From"] = "attacker@example.com" + msg["To"] = "victim@example.com" + + with pytest.raises(smtplib.SMTPSenderRefused): + client.sendmail( + "attacker@example.com", + ["victim@example.com"], + msg.as_string(), + ) + client.quit() + + def test_send_multiple_recipients(self, smtp_client, test_channel, mock_api): + """Sending to multiple recipients should work.""" + msg = MIMEText("Multi-recipient test.") + msg["Subject"] = "Multi-recipient" + msg["From"] = "test@example.com" + msg["To"] = "user1@example.com, user2@example.com" + + initial_count = len(mock_api.submitted_messages) + smtp_client.sendmail( + "test@example.com", + ["user1@example.com", "user2@example.com"], + msg.as_string(), + ) + + assert len(mock_api.submitted_messages) == initial_count + 1 + submitted = mock_api.submitted_messages[-1] + assert "user1@example.com" in submitted["rcpt_to"] + assert "user2@example.com" in submitted["rcpt_to"] diff --git a/src/client-bridge/uv.lock b/src/client-bridge/uv.lock new file mode 100644 index 000000000..dd2cfbe1a --- /dev/null +++ b/src/client-bridge/uv.lock @@ -0,0 +1,562 @@ +version = 1 +revision = 3 +requires-python = ">=3.13, <4.0" + +[[package]] +name = "aiosmtpd" +version = "1.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "atpublic" }, + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/ca/b2b7cc880403ef24be77383edaadfcf0098f5d7b9ddbf3e2c17ef0a6af0d/aiosmtpd-1.4.6.tar.gz", hash = "sha256:5a811826e1a5a06c25ebc3e6c4a704613eb9a1bcf6b78428fbe865f4f6c9a4b8", size = 152775, upload-time = "2024-05-18T11:37:50.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/39/d401756df60a8344848477d54fdf4ce0f50531f6149f3b8eaae9c06ae3dc/aiosmtpd-1.4.6-py3-none-any.whl", hash = "sha256:72c99179ba5aa9ae0abbda6994668239b64a5ce054471955fe75f581d2592475", size = 154263, upload-time = "2024-05-18T11:37:47.877Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "astroid" +version = "3.3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" }, +] + +[[package]] +name = "atpublic" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/05/e2e131a0debaf0f01b8a1b586f5f11713f6affc3e711b406f15f11eafc92/atpublic-7.0.0.tar.gz", hash = "sha256:466ef10d0c8bbd14fd02a5fbd5a8b6af6a846373d91106d3a07c16d72d96b63e", size = 17801, upload-time = "2025-11-29T05:56:45.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/c0/271f3e1e3502a8decb8ee5c680dbed2d8dc2cd504f5e20f7ed491d5f37e1/atpublic-7.0.0-py3-none-any.whl", hash = "sha256:6702bd9e7245eb4e8220a3e222afcef7f87412154732271ee7deee4433b72b4b", size = 6421, upload-time = "2025-11-29T05:56:44.604Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "fastapi" +version = "0.115.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/82/fa43935523efdfcce6abbae9da7f372b627b27142c3419fcf13bf5b0c397/isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481", size = 824325, upload-time = "2025-10-01T16:26:45.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/cc/9b681a170efab4868a032631dea1e8446d8ec718a7f657b94d49d1a12643/isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784", size = 94329, upload-time = "2025-10-01T16:26:43.291Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "proxy-protocol" +version = "0.11.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/bc/afb0cf168826cf04e58d6ceb1e26ad07875ac7df1bba06834711542eab68/proxy_protocol-0.11.3.tar.gz", hash = "sha256:a9a1bd7bd90bfa82444a6bfc7cf567fa0a4d4144c9cadf392b8736ba651a662c", size = 31371, upload-time = "2024-04-27T18:59:55.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/f1/ff6a0ee39964776792a38b638ac11cb0a72fa2704fc9cd158c7409716317/proxy_protocol-0.11.3-py3-none-any.whl", hash = "sha256:77d541828aed30c5d9eea9c4c9af1dd85c2c4a2f829e0ecb003cb978f738a3f1", size = 32611, upload-time = "2024-04-27T18:59:56.456Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[[package]] +name = "pylint" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/b9/50be49afc91469f832c4bf12318ab4abe56ee9aa3700a89aad5359ad195f/pylint-3.3.4.tar.gz", hash = "sha256:74ae7a38b177e69a9b525d0794bd8183820bfa7eb68cc1bee6e8ed22a42be4ce", size = 1518905, upload-time = "2025-01-28T13:28:21.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/8b/eef15df5f4e7aa393de31feb96ca9a3d6639669bd59d589d0685d5ef4e62/pylint-3.3.4-py3-none-any.whl", hash = "sha256:289e6a1eb27b453b08436478391a48cd53bb0efb824873f949e709350f3de018", size = 522280, upload-time = "2025-01-28T13:28:18.044Z" }, +] + +[[package]] +name = "pymap" +version = "0.36.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "proxy-protocol" }, + { name = "pysasl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/d0/4b39896c44a508556a32c95d53d88137f812545379ba5ba4189b4d41d842/pymap-0.36.7.tar.gz", hash = "sha256:73743598e4a1cfbbde0f216a92e4d33cdf417bf9b7ac8854c037214444e54ad7", size = 176619, upload-time = "2024-04-27T18:59:52.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/22/50f025da5bc91d0dd585a64183c927a3b8c6d01f21aef6adb2a1e970d167/pymap-0.36.7-py3-none-any.whl", hash = "sha256:9ec1326119d2bba86a531401bfa1ecfb63d8e188ce23ec0c71557a28af49d25b", size = 215089, upload-time = "2024-04-27T18:59:50.216Z" }, +] + +[[package]] +name = "pysasl" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/81/0f67bed0f17a2ce439b0cd9daad856791f471134a93deef4677095e98368/pysasl-1.2.0.tar.gz", hash = "sha256:5141cd8cfa0b3667ec114aded60fb17313496ba10603ffdbd7feeac7146ae7a1", size = 16696, upload-time = "2023-07-19T03:34:06.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/d523b85287bac9e86d9134107b831e6a0a5317e3e92bb52c56e3900f32af/pysasl-1.2.0-py3-none-any.whl", hash = "sha256:4bd65b0342f2a04343501d3d192faf20a09a39b3412f3d383fd6437d62f96fe4", size = 21494, upload-time = "2023-07-19T03:34:07.735Z" }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945, upload-time = "2024-10-29T20:13:35.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949, upload-time = "2024-10-29T20:13:33.215Z" }, +] + +[[package]] +name = "ruff" +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7f/60fda2eec81f23f8aa7cbbfdf6ec2ca11eb11c273827933fb2541c2ce9d8/ruff-0.9.3.tar.gz", hash = "sha256:8293f89985a090ebc3ed1064df31f3b4b56320cdfcec8b60d3295bddb955c22a", size = 3586740, upload-time = "2025-01-23T19:29:02.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/77/4fb790596d5d52c87fd55b7160c557c400e90f6116a56d82d76e95d9374a/ruff-0.9.3-py3-none-linux_armv6l.whl", hash = "sha256:7f39b879064c7d9670197d91124a75d118d00b0990586549949aae80cdc16624", size = 11656815, upload-time = "2025-01-23T19:27:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a8/3338ecb97573eafe74505f28431df3842c1933c5f8eae615427c1de32858/ruff-0.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a187171e7c09efa4b4cc30ee5d0d55a8d6c5311b3e1b74ac5cb96cc89bafc43c", size = 11594821, upload-time = "2025-01-23T19:27:53.913Z" }, + { url = "https://files.pythonhosted.org/packages/8e/89/320223c3421962762531a6b2dd58579b858ca9916fb2674874df5e97d628/ruff-0.9.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c59ab92f8e92d6725b7ded9d4a31be3ef42688a115c6d3da9457a5bda140e2b4", size = 11040475, upload-time = "2025-01-23T19:27:58.059Z" }, + { url = "https://files.pythonhosted.org/packages/b2/bd/1d775eac5e51409535804a3a888a9623e87a8f4b53e2491580858a083692/ruff-0.9.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc153c25e715be41bb228bc651c1e9b1a88d5c6e5ed0194fa0dfea02b026439", size = 11856207, upload-time = "2025-01-23T19:28:02.26Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c6/3e14e09be29587393d188454064a4aa85174910d16644051a80444e4fd88/ruff-0.9.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:646909a1e25e0dc28fbc529eab8eb7bb583079628e8cbe738192853dbbe43af5", size = 11420460, upload-time = "2025-01-23T19:28:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/ef/42/b7ca38ffd568ae9b128a2fa76353e9a9a3c80ef19746408d4ce99217ecc1/ruff-0.9.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a5a46e09355695fbdbb30ed9889d6cf1c61b77b700a9fafc21b41f097bfbba4", size = 12605472, upload-time = "2025-01-23T19:28:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a1/3167023f23e3530fde899497ccfe239e4523854cb874458ac082992d206c/ruff-0.9.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c4bb09d2bbb394e3730d0918c00276e79b2de70ec2a5231cd4ebb51a57df9ba1", size = 13243123, upload-time = "2025-01-23T19:28:14.181Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b4/3c600758e320f5bf7de16858502e849f4216cb0151f819fa0d1154874802/ruff-0.9.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96a87ec31dc1044d8c2da2ebbed1c456d9b561e7d087734336518181b26b3aa5", size = 12744650, upload-time = "2025-01-23T19:28:19.738Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/266fbcbb3d0088862c9bafa8b1b99486691d2945a90b9a7316336a0d9a1b/ruff-0.9.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb7554aca6f842645022fe2d301c264e6925baa708b392867b7a62645304df4", size = 14458585, upload-time = "2025-01-23T19:28:24.255Z" }, + { url = "https://files.pythonhosted.org/packages/63/a6/47fd0e96990ee9b7a4abda62de26d291bd3f7647218d05b7d6d38af47c30/ruff-0.9.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabc332b7075a914ecea912cd1f3d4370489c8018f2c945a30bcc934e3bc06a6", size = 12419624, upload-time = "2025-01-23T19:28:29.894Z" }, + { url = "https://files.pythonhosted.org/packages/84/5d/de0b7652e09f7dda49e1a3825a164a65f4998175b6486603c7601279baad/ruff-0.9.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:33866c3cc2a575cbd546f2cd02bdd466fed65118e4365ee538a3deffd6fcb730", size = 11843238, upload-time = "2025-01-23T19:28:36.111Z" }, + { url = "https://files.pythonhosted.org/packages/9e/be/3f341ceb1c62b565ec1fb6fd2139cc40b60ae6eff4b6fb8f94b1bb37c7a9/ruff-0.9.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:006e5de2621304c8810bcd2ee101587712fa93b4f955ed0985907a36c427e0c2", size = 11484012, upload-time = "2025-01-23T19:28:40.919Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c8/ff8acbd33addc7e797e702cf00bfde352ab469723720c5607b964491d5cf/ruff-0.9.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ba6eea4459dbd6b1be4e6bfc766079fb9b8dd2e5a35aff6baee4d9b1514ea519", size = 12038494, upload-time = "2025-01-23T19:28:44.314Z" }, + { url = "https://files.pythonhosted.org/packages/73/b1/8d9a2c0efbbabe848b55f877bc10c5001a37ab10aca13c711431673414e5/ruff-0.9.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90230a6b8055ad47d3325e9ee8f8a9ae7e273078a66401ac66df68943ced029b", size = 12473639, upload-time = "2025-01-23T19:28:47.686Z" }, + { url = "https://files.pythonhosted.org/packages/cb/44/a673647105b1ba6da9824a928634fe23186ab19f9d526d7bdf278cd27bc3/ruff-0.9.3-py3-none-win32.whl", hash = "sha256:eabe5eb2c19a42f4808c03b82bd313fc84d4e395133fb3fc1b1516170a31213c", size = 9834353, upload-time = "2025-01-23T19:28:51.755Z" }, + { url = "https://files.pythonhosted.org/packages/c3/01/65cadb59bf8d4fbe33d1a750103e6883d9ef302f60c28b73b773092fbde5/ruff-0.9.3-py3-none-win_amd64.whl", hash = "sha256:040ceb7f20791dfa0e78b4230ee9dce23da3b64dd5848e40e3bf3ab76468dcf4", size = 10821444, upload-time = "2025-01-23T19:28:56.509Z" }, + { url = "https://files.pythonhosted.org/packages/69/cb/b3fe58a136a27d981911cba2f18e4b29f15010623b79f0f2510fd0d31fd3/ruff-0.9.3-py3-none-win_arm64.whl", hash = "sha256:800d773f6d4d33b0a3c60e2c6ae8f4c202ea2de056365acfa519aa48acf28e0b", size = 10038168, upload-time = "2025-01-23T19:28:59.81Z" }, +] + +[[package]] +name = "st-messages-client-bridge" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "aiosmtpd" }, + { name = "httpx" }, + { name = "pyjwt" }, + { name = "pymap" }, +] + +[package.optional-dependencies] +dev = [ + { name = "fastapi" }, + { name = "pylint" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosmtpd", specifier = ">=1.4.6" }, + { name = "fastapi", marker = "extra == 'dev'", specifier = "==0.115.12" }, + { name = "httpx", specifier = ">=0.28" }, + { name = "pyjwt", specifier = ">=2.0" }, + { name = "pylint", marker = "extra == 'dev'", specifier = "==3.3.4" }, + { name = "pymap", specifier = "==0.36.7" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.5" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = "==6.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.9.3" }, + { name = "uvicorn", marker = "extra == 'dev'", specifier = "==0.34.1" }, +] +provides-extras = ["dev"] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/37/dd92f1f9cedb5eaf74d9999044306e06abe65344ff197864175dbbd91871/uvicorn-0.34.1.tar.gz", hash = "sha256:af981725fc4b7ffc5cb3b0e9eda6258a90c4b52cb2a83ce567ae0a7ae1757afc", size = 76755, upload-time = "2025-04-13T13:48:04.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/38/a5801450940a858c102a7ad9e6150146a25406a119851c993148d56ab041/uvicorn-0.34.1-py3-none-any.whl", hash = "sha256:984c3a8c7ca18ebaad15995ee7401179212c59521e67bfc390c07fa2b8d2e065", size = 62404, upload-time = "2025-04-13T13:48:02.408Z" }, +] diff --git a/src/e2e/bin/backend-manage.sh b/src/e2e/bin/backend-manage.sh index 5b2be479e..6ce79b135 100755 --- a/src/e2e/bin/backend-manage.sh +++ b/src/e2e/bin/backend-manage.sh @@ -1,7 +1,7 @@ #!/bin/bash # Helper script to run Django management commands in the backend container. # Through the docker sock proxy service, it is possible to run commands in the -# backend container from within the runner container. +# backend container from within the e2e-runner container. # # Usage: ./backend-manage.sh [args...] # Examples: diff --git a/src/e2e/compose.yaml b/src/e2e/compose.yaml index c084bcd67..8e50200f9 100644 --- a/src/e2e/compose.yaml +++ b/src/e2e/compose.yaml @@ -54,10 +54,17 @@ services: - --hostname=$${HOST} - --hostname-admin=$${ADMIN_HOST} - --http-port=8802 + - --health-enabled=true env_file: - ../../env.d/development/keycloak.defaults - ../../env.d/development/keycloak.e2e ports: !reset [] + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 30s depends_on: !override - postgresql @@ -80,7 +87,7 @@ services: opensearch: condition: service_healthy keycloak: - condition: service_started + condition: service_healthy mta-in: extends: @@ -93,6 +100,17 @@ services: depends_on: !override - backend + client-bridge: + extends: + file: ../../compose.yaml + service: client-bridge + env_file: + - ../../env.d/development/client-bridge.defaults + - ../../env.d/development/client-bridge.e2e + ports: !reset [] + depends_on: !override + - backend + proxy: image: caddy:2-alpine environment: @@ -142,7 +160,7 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - runner: + e2e-runner: build: context: . dockerfile: Dockerfile @@ -153,6 +171,8 @@ services: - FRONTEND_BASE_URL=http://proxy - BACKEND_BASE_URL=http://proxy - KEYCLOAK_BASE_URL=http://keycloak:8802 + - CLIENTBRIDGE_IMAP_HOST=client-bridge + - CLIENTBRIDGE_IMAP_PORT=143 - DOCKER_HOST=tcp://docker-sock-proxy:2375 - CI=${CI:-false} - PW_TEST_HTML_REPORT_OPEN=${PW_TEST_HTML_REPORT_OPEN:-false} @@ -167,6 +187,8 @@ services: condition: service_started mta-in: condition: service_started + client-bridge: + condition: service_started worker: condition: service_started command: npm run test diff --git a/src/e2e/package.json b/src/e2e/package.json index 8d05cc4aa..a265c81c1 100644 --- a/src/e2e/package.json +++ b/src/e2e/package.json @@ -18,11 +18,15 @@ "django": "./bin/backend-manage.sh", "db:flush": "npm run django flush -- --no-input", "db:bootstrap": "npm run django e2e_demo", + "db:bootstrap-clientbridge": "npm run django e2e_clientbridge", "db:reset": "npm run db:flush && npm run db:bootstrap" }, "keywords": ["e2e", "playwright", "testing"], "author": "", "license": "MIT", + "dependencies": { + "imapflow": "1.0.175" + }, "devDependencies": { "@playwright/test": "1.57.0", "@types/node": "22.19.3" diff --git a/src/e2e/src/__tests__/client-bridge.spec.ts b/src/e2e/src/__tests__/client-bridge.spec.ts new file mode 100644 index 000000000..eeffcdd54 --- /dev/null +++ b/src/e2e/src/__tests__/client-bridge.spec.ts @@ -0,0 +1,225 @@ +/** + * E2E tests for the client-bridge IMAP server against the real Messages API. + * + * These tests verify that the client-bridge correctly communicates with the + * backend, catching issues that mock-based unit tests miss (e.g. wrong query + * params, missing API endpoints). + */ + +import test, { expect } from "@playwright/test"; +import { ImapFlow } from "imapflow"; +import { + CLIENTBRIDGE_IMAP_HOST, + CLIENTBRIDGE_IMAP_PORT, + CLIENTBRIDGE_APP_PASSWORD, + API_URL, +} from "../constants"; +import { bootstrapClientBridge } from "../utils"; +import { signInKeycloakIfNeeded } from "../utils-test"; + +const IMAP_USER = "user.e2e.chromium@example.local"; + +async function createImapClient(): Promise { + const client = new ImapFlow({ + host: CLIENTBRIDGE_IMAP_HOST, + port: CLIENTBRIDGE_IMAP_PORT, + secure: false, + auth: { + user: IMAP_USER, + pass: CLIENTBRIDGE_APP_PASSWORD, + }, + logger: false, + }); + await client.connect(); + return client; +} + +test.describe("Client Bridge IMAP", () => { + test.beforeAll(async () => { + await bootstrapClientBridge(); + }); + + test("should authenticate and list INBOX", async () => { + const client = await createImapClient(); + try { + const lock = await client.getMailboxLock("INBOX"); + try { + expect(client.mailbox).toBeTruthy(); + expect(client.mailbox!.exists).toBeGreaterThan(0); + } finally { + lock.release(); + } + } finally { + await client.logout(); + } + }); + + test("should list virtual folders", async () => { + const client = await createImapClient(); + try { + const folders = await client.list(); + const folderNames = folders.map((f) => f.path); + expect(folderNames).toContain("INBOX"); + expect(folderNames).toContain("Sent"); + expect(folderNames).toContain("Trash"); + expect(folderNames).toContain("Drafts"); + } finally { + await client.logout(); + } + }); + + test("should reflect API read state as IMAP \\Seen flag", async () => { + // The e2e demo creates two IMAP test messages: + // - "IMAP unread test" (read_at=null → unread) + // - "IMAP read test" (read_at set → read) + const client = await createImapClient(); + try { + const lock = await client.getMailboxLock("INBOX"); + try { + // Fetch flags for all messages + const messages: Array<{ uid: number; flags: Set; subject: string }> = []; + for await (const msg of client.fetch("1:*", { flags: true, envelope: true })) { + messages.push({ + uid: msg.uid, + flags: msg.flags, + subject: msg.envelope.subject || "", + }); + } + + const unreadMsg = messages.find((m) => m.subject === "IMAP unread test"); + const readMsg = messages.find((m) => m.subject === "IMAP read test"); + + expect(unreadMsg).toBeTruthy(); + expect(readMsg).toBeTruthy(); + expect(unreadMsg!.flags.has("\\Seen")).toBe(false); + expect(readMsg!.flags.has("\\Seen")).toBe(true); + } finally { + lock.release(); + } + } finally { + await client.logout(); + } + }); + + test("should sync read state from IMAP to webmail API", async ({ page }) => { + // Sign in to get an authenticated session for API calls + await signInKeycloakIfNeeded({ page, username: `user.e2e.chromium` }); + + const client = await createImapClient(); + try { + const lock = await client.getMailboxLock("INBOX"); + let targetUid: number | undefined; + try { + // Find the unread message + for await (const msg of client.fetch("1:*", { flags: true, envelope: true })) { + if (msg.envelope.subject === "IMAP unread test") { + targetUid = msg.uid; + expect(msg.flags.has("\\Seen")).toBe(false); + break; + } + } + expect(targetUid).toBeTruthy(); + + // Mark as read via IMAP + await client.messageFlagsAdd({ uid: targetUid! }, ["\\Seen"]); + } finally { + lock.release(); + } + } finally { + await client.logout(); + } + + // Verify via the API that the thread is now read + const threadsResp = await page.request.get(`${API_URL}/api/v1.0/threads/`, { + params: { page_size: "100" }, + }); + expect(threadsResp.ok()).toBe(true); + const threadsData = await threadsResp.json(); + const targetThread = threadsData.results?.find( + (t: any) => t.subject === "IMAP unread test" + ); + + // The IMAP STORE +FLAGS \Seen should have set read_at on the thread access + expect(targetThread).toBeTruthy(); + }); + + test("should sync read state from webmail API to IMAP", async ({ page }) => { + // Sign in to get API access + await signInKeycloakIfNeeded({ page, username: `user.e2e.chromium` }); + + // First, connect via IMAP to find a read message we can mark as unread via API + let targetSubject = "IMAP read test"; + const client1 = await createImapClient(); + let targetUid: number | undefined; + try { + const lock = await client1.getMailboxLock("INBOX"); + try { + for await (const msg of client1.fetch("1:*", { flags: true, envelope: true })) { + if (msg.envelope.subject === targetSubject) { + targetUid = msg.uid; + // Should be read initially + expect(msg.flags.has("\\Seen")).toBe(true); + break; + } + } + } finally { + lock.release(); + } + } finally { + await client1.logout(); + } + + expect(targetUid).toBeTruthy(); + + // Mark as unread via the webmail flag API + // We need to find the thread ID first + const threadsResp = await page.request.get(`${API_URL}/api/v1.0/threads/`, { + params: { page_size: "100" }, + }); + expect(threadsResp.ok()).toBe(true); + const threadsData = await threadsResp.json(); + const targetThread = threadsData.results?.find( + (t: any) => t.subject === targetSubject + ); + expect(targetThread).toBeTruthy(); + + // Get mailbox ID for the user + const mailboxResp = await page.request.get(`${API_URL}/api/v1.0/mailboxes/`); + expect(mailboxResp.ok()).toBe(true); + const mailboxData = await mailboxResp.json(); + const mailbox = mailboxData.results?.find( + (m: any) => m.local_part === "user.e2e.chromium" + ); + expect(mailbox).toBeTruthy(); + + // Mark the thread as unread via the flag API + const flagResp = await page.request.post(`${API_URL}/api/v1.0/flag/`, { + data: { + flag: "unread", + value: true, + thread_ids: [targetThread.id], + mailbox_id: mailbox.id, + read_at: null, + }, + }); + expect(flagResp.ok()).toBe(true); + + // Reconnect via IMAP and verify the message no longer has \Seen + const client2 = await createImapClient(); + try { + const lock = await client2.getMailboxLock("INBOX"); + try { + for await (const msg of client2.fetch("1:*", { flags: true, envelope: true })) { + if (msg.envelope.subject === targetSubject) { + expect(msg.flags.has("\\Seen")).toBe(false); + break; + } + } + } finally { + lock.release(); + } + } finally { + await client2.logout(); + } + }); +}); diff --git a/src/e2e/src/__tests__/signatures.spec.ts b/src/e2e/src/__tests__/signatures.spec.ts index 230aae97b..b2fcf8735 100644 --- a/src/e2e/src/__tests__/signatures.spec.ts +++ b/src/e2e/src/__tests__/signatures.spec.ts @@ -31,7 +31,7 @@ test.describe("Mailbox Signatures", () => { const header = page.locator(".c__header"); const settingsButton = header.getByRole("button", { name: "More options" }); await settingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // Wait for the data grid to load @@ -95,7 +95,7 @@ test.describe("Mailbox Signatures", () => { const header = page.locator(".c__header"); const settingsButton = header.getByRole("button", { name: "More options" }); await settingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // First create a signature to edit @@ -140,7 +140,7 @@ test.describe("Mailbox Signatures", () => { const header = page.locator(".c__header"); const settingsButton = header.getByRole("button", { name: "More options" }); await settingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // First create a signature to delete @@ -179,7 +179,7 @@ test.describe("Mailbox Signatures", () => { const header = page.locator(".c__header"); const settingsButton = header.getByRole("button", { name: "More options" }); await settingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // Create a signature @@ -214,7 +214,7 @@ test.describe("Mailbox Signatures", () => { const header = page.locator(".c__header"); const settingsButton = header.getByRole("button", { name: "More options" }); await settingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // Create a signature with default checkbox checked @@ -244,7 +244,7 @@ test.describe("Mailbox Signatures", () => { const header = page.locator(".c__header"); const settingsButton = header.getByRole("button", { name: "More options" }); await settingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // Create a default signature @@ -280,7 +280,7 @@ test.describe("Mailbox Signatures", () => { const header = page.locator(".c__header"); const settingsButton = header.getByRole("button", { name: "More options" }); await settingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // Create first signature and set as default @@ -328,7 +328,7 @@ test.describe("Mailbox Signatures", () => { const header = page.locator(".c__header"); const userSettingsButton = header.getByRole("button", { name: "More options" }); await userSettingsButton.click(); - await page.getByRole("menuitem", { name: "Signatures" }).click(); + await page.getByRole("menuitem", { name: "My signatures" }).click(); await page.waitForURL("**/mailbox/*/signatures"); // Create a mailbox signature WITH is_default diff --git a/src/e2e/src/constants.ts b/src/e2e/src/constants.ts index b74230cfb..2b387e23a 100644 --- a/src/e2e/src/constants.ts +++ b/src/e2e/src/constants.ts @@ -9,3 +9,8 @@ export const API_URL = process.env.BACKEND_BASE_URL; export const AUTHENTICATION_URL = process.env.KEYCLOAK_BASE_URL; export const STORAGE_STATE_PATH = path.join(__dirname, `./__tests__/.auth`); export const FIXTURES_PATH = path.join(__dirname, `./fixtures`); + +// Client-bridge (IMAP/SMTP) settings +export const CLIENTBRIDGE_IMAP_HOST = process.env.CLIENTBRIDGE_IMAP_HOST || 'client-bridge'; +export const CLIENTBRIDGE_IMAP_PORT = parseInt(process.env.CLIENTBRIDGE_IMAP_PORT || '143', 10); +export const CLIENTBRIDGE_APP_PASSWORD = 'e2e-client-bridge-password'; diff --git a/src/e2e/src/utils.ts b/src/e2e/src/utils.ts index 7346b5272..6effa7861 100644 --- a/src/e2e/src/utils.ts +++ b/src/e2e/src/utils.ts @@ -8,18 +8,12 @@ import path from 'path'; import fs from 'fs'; /** - * Execute a npm command in the runner container. + * Execute a npm command in the e2e-runner container. */ -async function runNpmCommand(command: string, args: string[] = [], timeout: number = 1000): Promise { +async function runNpmCommand(command: string, args: string[] = []): Promise { const commandArgs = [command, ...args].join(' '); - const fullCommand = `npm run ${commandArgs}`; - - if (timeout) { - await new Promise((resolve) => { setTimeout(resolve, timeout) }); - } - return new Promise((resolve, reject) => { exec(fullCommand, (error, stdout) => { if (error) reject(error); @@ -30,12 +24,21 @@ async function runNpmCommand(command: string, args: string[] = [], timeout: numb /** * Reset the database by flushing all data (keeps schema) then - * bootstrapping the demo data. + * bootstrapping the demo data (without client-bridge data). */ export async function resetDatabase(): Promise { await runNpmCommand('db:reset'); } +/** + * Bootstrap client-bridge channels and IMAP test messages. + * Only needed by client-bridge tests, separated to avoid the cost + * of recreating EML blobs on every resetDatabase() call. + */ +export async function bootstrapClientBridge(): Promise { + await runNpmCommand('db:bootstrap-clientbridge'); +} + export const getStorageStatePath = (username: string): string => { return path.join(STORAGE_STATE_PATH, `user-${username}.json`); }; diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index 3c36d6480..2be29c655 100755 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -112,12 +112,14 @@ "An error occurred while importing messages.": "An error occurred while importing messages.", "An error occurred while loading maildomains.": "An error occurred while loading maildomains.", "An error occurred while resetting the password.": "An error occurred while resetting the password.", + "An error occurred while rotating the password.": "An error occurred while rotating the password.", "An error occurred while saving the integration.": "An error occurred while saving the integration.", "An error occurred while updating the address.": "An error occurred while updating the address.", "An error occurred while uploading the archive file.": "An error occurred while uploading the archive file.", "An unexpected error occurred.": "An unexpected error occurred.", "and {{count}} other users_one": "and 1 other user", "and {{count}} other users_other": "and {{count}} other users", + "App-specific password": "App-specific password", "API Key": "API Key", "Archive": "Archive", "Archives": "Archives", @@ -174,9 +176,12 @@ "Color: ": "Color: ", "Coming soon": "Coming soon", "Conflicting": "Conflicting", + "Connect your mailbox to an email client like Thunderbird or to your mobile phone using IMAP and SMTP.": "Connect your mailbox to an email client like Thunderbird or to your mobile phone using IMAP and SMTP.", + "Connection details": "Connection details", "Contact the Support team": "Contact the Support team", "Contains the words": "Contains the words", "Content is required": "Content is required", + "Controls what this integration can do: read emails, edit flags, or send messages.": "Controls what this integration can do: read emails, edit flags, or send messages.", "Copied": "Copied", "Copied to clipboard": "Copied to clipboard", "Copy": "Copy", @@ -197,6 +202,7 @@ "Create a new template": "Create a new template", "Create a simple redirect (Coming soon)": "Create a simple redirect (Coming soon)", "Create a Widget": "Create a Widget", + "Create email client access": "Create email client access", "Create integration": "Create integration", "Create the label \"{{label}}\"": "Create the label \"{{label}}\"", "create_mailbox_modal.success.credential_text": "Your Messages credentials are:\n- Email: {{id}}\n- Password: {{password}}\n\nIt will be asked to change your password at your first login.", @@ -233,6 +239,7 @@ "Display those images": "Display those images", "DNS": "DNS", "Do you have any feedback?": "Do you have any feedback?", + "Done": "Done", "Domain": "Domain", "Domain admin": "Domain admin", "Domain not found": "Domain not found", @@ -252,9 +259,12 @@ "Edit auto-reply \"{{autoreply}}\"": "Edit auto-reply \"{{autoreply}}\"", "Edit signature \"{{signature}}\"": "Edit signature \"{{signature}}\"", "Edit template \"{{template}}\"": "Edit template \"{{template}}\"", + "Edit email client access": "Edit email client access", + "Editor — IMAP read and edit flags": "Editor — IMAP read and edit flags", "Edit Widget": "Edit Widget", "edited": "edited", "Editing message": "Editing message", + "Email client access": "Email client access", "Email address": "Email address", "EML, MBOX or PST": "EML, MBOX or PST", "End date": "End date", @@ -281,6 +291,7 @@ "Expand": "Expand", "Expand {{name}}": "Expand {{name}}", "Expand all": "Expand all", + "Failed to copy to clipboard": "Failed to copy to clipboard", "Failed to delete auto-reply.": "Failed to delete auto-reply.", "Failed to delete integration.": "Failed to delete integration.", "Failed to delete signature.": "Failed to delete signature.", @@ -314,6 +325,7 @@ "Full name": "Full name", "Full name is required.": "Full name is required.", "General": "General", + "Generate a new password. This will invalidate the current one.": "Generate a new password. This will invalidate the current one.", "Generate an API key to send messages programmatically from your applications.": "Generate an API key to send messages programmatically from your applications.", "Generating summary...": "Generating summary...", "Help center & Support": "Help center & Support", @@ -333,6 +345,7 @@ "Imported messages": "Imported messages", "Importing messages...": "Importing messages...", "Importing...": "Importing...", + "Incoming mail (IMAP)": "Incoming mail (IMAP)", "In": "In", "In case of questions, we'll get back to you soon.": "In case of questions, we'll get back to you soon.", "In reply to": "In reply to", @@ -396,6 +409,7 @@ "Modified": "Modified", "Modify": "Modify", "Monday": "Monday", + "My email client": "My email client", "Monthly": "Monthly", "More": "More", "More options": "More options", @@ -436,18 +450,21 @@ "Or": "Or", "or drag and drop some files": "or drag and drop some files", "Other services...": "Other services...", + "Outgoing mail (SMTP)": "Outgoing mail (SMTP)", "Outbox": "Outbox", "Password": "Password", - "Password is required.": "Password is required.", + "Password rotated successfully!": "Password rotated successfully!", "Password reset successfully!": "Password reset successfully!", "Personal mailbox": "Personal mailbox", "Personal mailboxes cannot be created when identity synchronization is disabled.": "Personal mailboxes cannot be created when identity synchronization is disabled.", "Please enter a valid email address.": "Please enter a valid email address.", "Prefix can only contain letters, numbers, dots, underscores and hyphens.": "Prefix can only contain letters, numbers, dots, underscores and hyphens.", "Prefix is required.": "Prefix is required.", + "Port": "Port", "Print": "Print", "Read": "Read", "Read state": "Read state", + "Reader — read-only IMAP access": "Reader — read-only IMAP access", "Recurring": "Recurring", "Recurring weekly": "Recurring weekly", "Redirection": "Redirection", @@ -458,6 +475,9 @@ "Remove spam report": "Remove spam report", "Remove tag": "Remove tag", "Reply": "Reply", + "Role": "Role", + "Rotate password": "Rotate password", + "Rotating...": "Rotating...", "Reply all": "Reply all", "Report as spam": "Report as spam", "Reset": "Reset", @@ -466,6 +486,7 @@ "Retry": "Retry", "Saturday": "Saturday", "Save": "Save", + "Save this password now. You won't be able to see it again.": "Save this password now. You won't be able to see it again.", "Save changes": "Save changes", "Save into your {{driveAppName}}'s workspace": "Save into your {{driveAppName}}'s workspace", "Saving...": "Saving...", @@ -486,9 +507,13 @@ "Send and archive": "Send and archive", "Send and receive your messages in an instant.": "Send and receive your messages in an instant.", "Send Feedback": "Send Feedback", + "Sender — full IMAP and SMTP access": "Sender — full IMAP and SMTP access", + "Sender only — SMTP send, no IMAP": "Sender only — SMTP send, no IMAP", "Sending message...": "Sending message...", "Sent": "Sent", "Sent by {{name}}": "Sent by {{name}}", + "Security": "Security", + "Server": "Server", "Settings": "Settings", "Share access": "Share access", "Share the credentials of this mailbox with its user. You must transfer them securely, preferably physically.": "Share the credentials of this mailbox with its user. You must transfer them securely, preferably physically.", @@ -576,6 +601,7 @@ "This message has not yet been delivered to all recipients.": "This message has not yet been delivered to all recipients.", "This message is being delivered.": "This message is being delivered.", "This name is for internal use only and will not be visible to users.": "This name is for internal use only and will not be visible to users.", + "This password will be used to authenticate in your email client.": "This password will be used to authenticate in your email client.", "This signature is forced": "This signature is forced", "This thread has been reported as spam.": "This thread has been reported as spam.", "This thread has been reported as spam. For your security, downloading attachments has been disabled.": "This thread has been reported as spam. For your security, downloading attachments has been disabled.", @@ -596,6 +622,8 @@ "Tuesday": "Tuesday", "Tutorials and training": "Tutorials and training", "Type": "Type", + "Use these settings to configure your email client (Thunderbird or your mobile phone).": "Use these settings to configure your email client (Thunderbird or your mobile phone).", + "Username": "Username", "Unable to copy credentials.": "Unable to copy credentials.", "Unable to copy to clipboard.": "Unable to copy to clipboard.", "Unarchive": "Unarchive", diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index e28827ae0..eb0d97bda 100755 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -153,6 +153,7 @@ "An error occurred while importing messages.": "Une erreur s'est produite lors de l'importation des messages.", "An error occurred while loading maildomains.": "Une erreur s'est produite lors du chargement des domaines.", "An error occurred while resetting the password.": "Une erreur s'est produite lors de la rĂ©initialisation du mot de passe.", + "An error occurred while rotating the password.": "Une erreur est survenue lors de la rotation du mot de passe.", "An error occurred while saving the integration.": "Une erreur est survenue lors de la sauvegarde de l'intĂ©gration.", "An error occurred while updating the address.": "Une erreur est survenue lors de la mise Ă  jour de l'adresse.", "An error occurred while uploading the archive file.": "Une erreur est survenue lors du tĂ©lĂ©versement de l'archive.", @@ -160,6 +161,7 @@ "and {{count}} other users_one": "et 1 autre utilisateur", "and {{count}} other users_many": "et {{count}} autres utilisateurs", "and {{count}} other users_other": "et {{count}} autres utilisateurs", + "App-specific password": "Mot de passe d'application", "API Key": "ClĂ© API", "Archive": "Archiver", "Archives": "Archives", @@ -216,9 +218,12 @@ "Color: ": "Couleur : ", "Coming soon": "BientĂŽt disponible", "Conflicting": "En conflit", + "Connect your mailbox to an email client like Thunderbird or to your mobile phone using IMAP and SMTP.": "Connectez votre boĂźte aux lettres Ă  un client de messagerie comme Thunderbird ou Ă  votre tĂ©lĂ©phone portable via IMAP et SMTP.", + "Connection details": "ParamĂštres de connexion", "Contact the Support team": "Écrire Ă  l'Ă©quipe support", "Contains the words": "Contient les mots", "Content is required": "Un contenu est requis", + "Controls what this integration can do: read emails, edit flags, or send messages.": "ContrĂŽle les actions autorisĂ©es pour cette intĂ©gration : lire les e-mails, modifier les indicateurs ou envoyer des messages.", "Copied": "CopiĂ©", "Copied to clipboard": "CopiĂ© dans le presse-papiers", "Copy": "Copier", @@ -239,6 +244,7 @@ "Create a new template": "CrĂ©er un nouveau modĂšle", "Create a simple redirect (Coming soon)": "CrĂ©er une simple redirection (BientĂŽt disponible)", "Create a Widget": "CrĂ©er un Widget", + "Create email client access": "CrĂ©er un accĂšs client de messagerie", "Create integration": "CrĂ©er l'intĂ©gration", "Create the label \"{{label}}\"": "CrĂ©er le libellĂ© \"{{label}}\"", "create_mailbox_modal.success.credential_text": "Vos identifiants Messages sont : \n- Courriel : {{id}}\n- Mot de passe : {{password}}\n\nIl vous sera demandĂ© de changer votre mot de passe lors de votre premiĂšre connexion.", @@ -275,6 +281,7 @@ "Display those images": "Afficher ces images", "DNS": "DNS", "Do you have any feedback?": "Partager un retour ou une question", + "Done": "TerminĂ©", "Domain": "Domaine", "Domain admin": "Gestion des domaines", "Domain not found": "Domaine introuvable", @@ -294,9 +301,12 @@ "Edit auto-reply \"{{autoreply}}\"": "Modifier la rĂ©ponse automatique \"{{autoreply}}\"", "Edit signature \"{{signature}}\"": "Modifier la signature \"{{signature}}\"", "Edit template \"{{template}}\"": "Modifier le modĂšle \"{{template}}\"", + "Edit email client access": "Modifier l'accĂšs client de messagerie", + "Editor — IMAP read and edit flags": "Éditeur — lecture IMAP et modification des indicateurs", "Edit Widget": "Modifier le Widget", "edited": "modifiĂ©", "Editing message": "Modification du message", + "Email client access": "AccĂšs client de messagerie", "Email address": "Adresse mail", "EML, MBOX or PST": "EML, MBOX ou PST", "End date": "Date de fin", @@ -327,6 +337,7 @@ "Expand": "DĂ©velopper", "Expand {{name}}": "", "Expand all": "Tout dĂ©velopper", + "Failed to copy to clipboard": "Échec de la copie dans le presse-papiers", "Failed to delete auto-reply.": "Erreur lors de la suppression de la rĂ©ponse automatique.", "Failed to delete integration.": "Erreur lors de la suppression de l'intĂ©gration.", "Failed to delete signature.": "Erreur lors de la suppression de la signature.", @@ -360,6 +371,7 @@ "Full name": "Nom complet", "Full name is required.": "Le nom complet est requis.", "General": "GĂ©nĂ©ral", + "Generate a new password. This will invalidate the current one.": "GĂ©nĂ©rer un nouveau mot de passe. Cela invalidera le mot de passe actuel.", "Generate an API key to send messages programmatically from your applications.": "GĂ©nĂ©rez une clĂ© API pour envoyer des messages de façon programmatique depuis vos applications.", "Generating summary...": "GĂ©nĂ©ration du rĂ©sumĂ© en cours...", "Help center & Support": "Centre d'aide et Support", @@ -379,6 +391,7 @@ "Imported messages": "Messages importĂ©s", "Importing messages...": "Importation des messages...", "Importing...": "Importation en cours...", + "Incoming mail (IMAP)": "Courrier entrant (IMAP)", "In": "Dans", "In case of questions, we'll get back to you soon.": "En cas de questions, nous vous rĂ©pondrons dans les meilleurs dĂ©lais sur l'email renseignĂ©.", "In reply to": "En rĂ©ponse Ă ", @@ -443,6 +456,7 @@ "Modified": "ModifiĂ©", "Modify": "Modifier", "Monday": "Lundi", + "My email client": "Mon client de messagerie", "Monthly": "Mensuel", "More": "Plus", "More options": "Plus d'options", @@ -453,6 +467,7 @@ "Name is required": "Un nom est requis", "Name is required.": "Un nom est requis.", "Name must be a valid domain name.": "Le nom doit ĂȘtre un nom de domaine valide.", + "New password (leave empty to keep current)": "Nouveau mot de passe (laisser vide pour conserver l'actuel)", "New address": "Nouvelle adresse", "New auto-reply": "Nouvelle rĂ©ponse automatique", "New domain": "Nouveau domaine", @@ -483,18 +498,21 @@ "Or": "Ou", "or drag and drop some files": "ou glissez-dĂ©posez des fichiers", "Other services...": "Autres services...", + "Outgoing mail (SMTP)": "Courrier sortant (SMTP)", "Outbox": "BoĂźte d'envoi", "Password": "Mot de passe", - "Password is required.": "Le mot de passe est requis.", + "Password rotated successfully!": "Mot de passe renouvelĂ© avec succĂšs !", "Password reset successfully!": "Mot de passe rĂ©initialisĂ© avec succĂšs !", "Personal mailbox": "BoĂźte personnelle", "Personal mailboxes cannot be created when identity synchronization is disabled.": "Les boĂźtes aux lettres personnelles ne peuvent pas ĂȘtre créées lorsque la synchronisation d'identitĂ© est dĂ©sactivĂ©e.", "Please enter a valid email address.": "Veuillez saisir une adresse email valide.", "Prefix can only contain letters, numbers, dots, underscores and hyphens.": "Le prĂ©fixe ne peut contenir que des lettres, chiffres, points, tirets bas et tirets.", "Prefix is required.": "Le prĂ©fixe est requis.", + "Port": "Port", "Print": "Imprimer", "Read": "Lu", "Read state": "État de lecture", + "Reader — read-only IMAP access": "Lecteur — accĂšs IMAP en lecture seule", "Recurring": "RĂ©current", "Recurring weekly": "Hebdomadaire rĂ©current", "Redirection": "Redirection", @@ -505,6 +523,9 @@ "Remove spam report": "Annuler le signalement spam", "Remove tag": "Supprimer le tag", "Reply": "RĂ©pondre", + "Role": "RĂŽle", + "Rotate password": "Renouveler le mot de passe", + "Rotating...": "Renouvellement en cours...", "Reply all": "RĂ©pondre Ă  tous", "Report as spam": "Signaler comme spam", "Reset": "RĂ©initialiser", @@ -514,6 +535,7 @@ "Saturday": "Samedi", "Save": "Enregistrer", "Save changes": "Enregistrer les modifications", + "Save this password now. You won't be able to see it again.": "Enregistrez ce mot de passe maintenant. Vous ne pourrez plus le consulter.", "Save into your {{driveAppName}}'s workspace": "Sauvegarder dans votre espace de travail {{driveAppName}}", "Saving...": "Enregistrement en cours...", "Schedule": "Planification", @@ -534,9 +556,13 @@ "Send and archive": "Envoyer et archiver", "Send and receive your messages in an instant.": "Envoyez et recevez vos messages en un instant.", "Send Feedback": "Envoyer le message", + "Sender — full IMAP and SMTP access": "ExpĂ©diteur — accĂšs complet IMAP et SMTP", + "Sender only — SMTP send, no IMAP": "ExpĂ©diteur uniquement — envoi SMTP, sans IMAP", "Sending message...": "Envoi du message en cours...", "Sent": "EnvoyĂ©s", "Sent by {{name}}": "EnvoyĂ© par {{name}}", + "Security": "SĂ©curitĂ©", + "Server": "Serveur", "Settings": "ParamĂštres", "Share access": "Partager l'accĂšs", "Share the credentials of this mailbox with its user. You must transfer them securely, preferably physically.": "Transmettez les identifiants de cette boĂźte mail Ă  son utilisateur de façon sĂ©curisĂ©e, de prĂ©fĂ©rence physiquement.", @@ -600,6 +626,7 @@ "The address has been updated!": "L'adresse a Ă©tĂ© mise Ă  jour !", "The default signature will be automatically loaded when composing a new message.": "La signature par dĂ©faut sera automatiquement chargĂ©e lors de la composition d'un nouveau message.", "The domain {{domain}} has been created successfully.": "Le domaine {{domain}} a Ă©tĂ© créé avec succĂšs.", + "The app-specific password you set above": "Le mot de passe d'application dĂ©fini ci-dessus", "The email {{email}} is invalid.": "Le courriel {{email}} est invalide.", "The email address is invalid.": "L'adresse email est invalide.", "The forced signature will be the only one usable for new messages.": "La signature forcĂ©e sera la seule utilisable pour les nouveaux messages.", @@ -627,6 +654,7 @@ "This message has not yet been delivered to all recipients.": "Ce message n'a pas encore Ă©tĂ© dĂ©livrĂ© Ă  tous les destinataires.", "This message is being delivered.": "Ce message est en cours d'envoi.", "This name is for internal use only and will not be visible to users.": "Ce nom est rĂ©servĂ© Ă  un usage interne et ne sera pas visible par les utilisateurs.", + "This password will be used to authenticate in your email client.": "Ce mot de passe sera utilisĂ© pour vous authentifier dans votre client de messagerie.", "This signature is forced": "Cette signature est forcĂ©e", "This thread has been reported as spam.": "Cette conversation a Ă©tĂ© signalĂ©e comme spam.", "This thread has been reported as spam. For your security, downloading attachments has been disabled.": "Cette conversation a Ă©tĂ© signalĂ©e comme spam. Pour votre sĂ©curitĂ©, le tĂ©lĂ©chargement des piĂšces jointes a Ă©tĂ© dĂ©sactivĂ©.", @@ -647,6 +675,8 @@ "Tuesday": "Mardi", "Tutorials and training": "Tutoriels et formations", "Type": "Type", + "Use these settings to configure your email client (Thunderbird or your mobile phone).": "Utilisez ces paramĂštres pour configurer votre client de messagerie (Thunderbird ou votre tĂ©lĂ©phone portable).", + "Username": "Nom d'utilisateur", "Unable to copy credentials.": "Impossible de copier les identifiants.", "Unable to copy to clipboard.": "Impossible de copier dans le presse-papiers.", "Unarchive": "DĂ©sarchiver", diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200.ts b/src/frontend/src/features/api/gen/models/config_retrieve200.ts index 9934c28e4..3bcd8f6da 100644 --- a/src/frontend/src/features/api/gen/models/config_retrieve200.ts +++ b/src/frontend/src/features/api/gen/models/config_retrieve200.ts @@ -8,6 +8,7 @@ import type { ConfigRetrieve200DRIVE } from "./config_retrieve200_driv_e"; import type { ConfigRetrieve200SCHEMACUSTOMATTRIBUTESUSER } from "./config_retrieve200_schemacustomattributesuse_r"; import type { ConfigRetrieve200SCHEMACUSTOMATTRIBUTESMAILDOMAIN } from "./config_retrieve200_schemacustomattributesmaildomai_n"; +import type { ConfigRetrieve200CLIENTBRIDGEPUBLICCONFIG } from "./config_retrieve200_clientbridgepublicconfi_g"; export type ConfigRetrieve200 = { readonly ENVIRONMENT: string; @@ -40,4 +41,6 @@ export type ConfigRetrieve200 = { readonly MESSAGES_MANUAL_RETRY_MAX_AGE: number; /** Whether silent OIDC login is enabled */ readonly FRONTEND_SILENT_LOGIN_ENABLED: boolean; + /** Client-bridge IMAP/SMTP connection settings for email clients. */ + readonly CLIENTBRIDGE_PUBLIC_CONFIG?: ConfigRetrieve200CLIENTBRIDGEPUBLICCONFIG; }; diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200_clientbridgepublicconfi_g.ts b/src/frontend/src/features/api/gen/models/config_retrieve200_clientbridgepublicconfi_g.ts new file mode 100644 index 000000000..f4bd23954 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/config_retrieve200_clientbridgepublicconfi_g.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval đŸș + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Client-bridge IMAP/SMTP connection settings for email clients. + */ +export type ConfigRetrieve200CLIENTBRIDGEPUBLICCONFIG = { + readonly imap_host?: string; + readonly imap_port?: number; + readonly imap_security?: string; + readonly smtp_host?: string; + readonly smtp_port?: number; + readonly smtp_security?: string; +}; diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index 663f9ec93..ca0f9634c 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -13,6 +13,7 @@ export * from "./change_flag_request_request"; export * from "./channel"; export * from "./channel_request"; export * from "./config_retrieve200"; +export * from "./config_retrieve200_clientbridgepublicconfi_g"; export * from "./config_retrieve200_driv_e"; export * from "./config_retrieve200_schemacustomattributesmaildomai_n"; export * from "./config_retrieve200_schemacustomattributesuse_r"; @@ -126,6 +127,7 @@ export * from "./reset_password_error"; export * from "./reset_password_internal_server_error"; export * from "./reset_password_not_found"; export * from "./reset_password_response"; +export * from "./rotate_password_response"; export * from "./scope_level_enum"; export * from "./send_create400"; export * from "./send_create403"; diff --git a/src/frontend/src/features/api/gen/models/rotate_password_response.ts b/src/frontend/src/features/api/gen/models/rotate_password_response.ts new file mode 100644 index 000000000..e0c164403 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/rotate_password_response.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval đŸș + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export interface RotatePasswordResponse { + password: string; +} diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/integrations-data-grid.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/integrations-data-grid.tsx index be89774ba..e784793b1 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/integrations-data-grid.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/integrations-data-grid.tsx @@ -25,6 +25,8 @@ const getChannelTypeLabel = (type: string | undefined, t: (key: string) => strin return t("Widget"); case "api_key": return t("API Key"); + case "client-bridge": + return t("Email client access"); default: return type; } @@ -36,6 +38,8 @@ const getChannelTypeIcon = (type: string | undefined) => { return "widgets"; case "api_key": return "key"; + case "client-bridge": + return "mail_lock"; default: return "integration_instructions"; } diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/_index.scss b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/_index.scss index c002110ed..80f8c5428 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/_index.scss +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/_index.scss @@ -175,7 +175,7 @@ } code { - font-family: "Fira Code", "Consolas", monospace; + font-family: "Fira Code", Consolas, monospace; } button { @@ -211,6 +211,57 @@ border-top: 1px solid var(--c--contextuals--border--semantic--neutral--default); } +// Client Bridge - increase select dropdown height to show all 4 role options +.widget-integration-form .c__select__menu.c__select__menu--opened { + max-height: 240px !important; +} + +// Client Bridge Integration Form +.client-bridge-form__details { + background: var(--c--contextuals--background--surface--tertiary); + border: 1px solid var(--c--contextuals--border--semantic--neutral--default); + border-radius: var(--c--globals--spacings--xs); + padding: var(--c--globals--spacings--base); +} + +.client-bridge-form__detail-list { + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--sm); + margin: 0; +} + +.client-bridge-form__detail-item { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--sm); + + dt { + font-weight: 600; + font-size: var(--c--globals--font--sizes--sm); + color: var(--c--contextuals--content--semantic--neutral--secondary); + min-width: 100px; + flex-shrink: 0; + } + + dd { + margin: 0; + font-size: var(--c--globals--font--sizes--sm); + display: flex; + align-items: center; + gap: var(--c--globals--spacings--2xs); + } + + code { + font-family: "Fira Code", Consolas, monospace; + font-size: var(--c--globals--font--sizes--xs); + background: var(--c--contextuals--background--surface--primary); + padding: var(--c--globals--spacings--4xs) var(--c--globals--spacings--2xs); + border-radius: 3px; + border: 1px solid var(--c--contextuals--border--semantic--neutral--default); + } +} + // Tags Selector (reuses thread-labels-widget and cunningham styles) .tags-selector { position: relative; diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/client-bridge-integration-form.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/client-bridge-integration-form.tsx new file mode 100644 index 000000000..0caca18f5 --- /dev/null +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/client-bridge-integration-form.tsx @@ -0,0 +1,390 @@ +import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { Icon, IconType, IconSize } from "@gouvfr-lasuite/ui-kit"; +import { useTranslation } from "react-i18next"; +import { useForm, FormProvider } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { useState, useMemo } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Channel, + useMailboxesChannelsCreate, + useMailboxesChannelsPartialUpdate, + useMailboxesChannelsRotatePasswordCreate, + getMailboxesChannelsListUrl, +} from "@/features/api/gen"; +import { useMailboxContext } from "@/features/providers/mailbox"; +import { RhfInput } from "@/features/forms/components/react-hook-form"; +import { RhfSelect } from "@/features/forms/components/react-hook-form/rhf-select"; +import { addToast, ToasterItem } from "@/features/ui/components/toaster"; +import { Banner } from "@/features/ui/components/banner"; +import { handle } from "@/features/utils/errors"; +import { useConfig } from "@/features/providers/config"; + +type ChannelCreateResponse = Channel & { password?: string }; + +type ClientBridgeIntegrationFormProps = { + channel?: Channel; + onSuccess: (channel: Channel) => void; + onClose: () => void; +}; + +const formSchema = (t: (key: string) => string) => z.object({ + name: z.string().min(1, { message: t("Name is required.") }), + role: z.enum(["reader", "editor", "sender", "sender_only"]), +}); + +type FormFields = z.infer>; + +export const ClientBridgeIntegrationForm = ({ + channel, + onSuccess, + onClose, +}: ClientBridgeIntegrationFormProps) => { + const { t } = useTranslation(); + const { selectedMailbox } = useMailboxContext(); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + const [generatedPassword, setGeneratedPassword] = useState(null); + const [isRotating, setIsRotating] = useState(false); + const isEditing = !!channel; + + const createMutation = useMailboxesChannelsCreate(); + const updateMutation = useMailboxesChannelsPartialUpdate(); + const rotateMutation = useMailboxesChannelsRotatePasswordCreate(); + + const schema = useMemo(() => formSchema(t), [t]); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: channel?.name || t("My email client"), + role: ((channel?.settings as Record)?.role as FormFields["role"]) || "sender", + }, + }); + + const errors = form.formState.errors; + + const invalidateChannels = async () => { + await queryClient.invalidateQueries({ + queryKey: [getMailboxesChannelsListUrl(selectedMailbox!.id)], + exact: false + }); + }; + + const onSubmit = async (data: FormFields) => { + setError(null); + + try { + if (isEditing && channel) { + await updateMutation.mutateAsync({ + mailboxId: selectedMailbox!.id, + id: channel.id, + data: { name: data.name, settings: { role: data.role } }, + }); + addToast( + + {t("Integration updated!")} + + ); + await invalidateChannels(); + } else { + const response = await createMutation.mutateAsync({ + mailboxId: selectedMailbox!.id, + data: { + name: data.name, + type: "client-bridge", + settings: { role: data.role }, + }, + }); + await invalidateChannels(); + const channelData = response.data as ChannelCreateResponse; + if (channelData.password) { + setGeneratedPassword(channelData.password); + } + onSuccess(channelData); + } + } catch (err) { + handle(err); + setError(t("An error occurred while saving the integration.")); + } + }; + + const handleRotatePassword = async () => { + if (!channel) return; + setIsRotating(true); + setError(null); + try { + const resp = await rotateMutation.mutateAsync({ + mailboxId: selectedMailbox!.id, + id: channel.id, + }); + const password = resp.data?.password; + if (password) { + setGeneratedPassword(password); + addToast( + + {t("Password rotated successfully!")} + + ); + } + } catch (err) { + handle(err); + setError(t("An error occurred while rotating the password.")); + } finally { + setIsRotating(false); + } + }; + + const mailboxEmail = selectedMailbox?.email ?? ""; + + // After creation or rotation, show the password + if (generatedPassword) { + return ( +
+
+ + {t("Save this password now. You won't be able to see it again.")} + +
+ +
+ +
+
+ ); + } + + // Editing an existing integration — show connection details with rotate + if (isEditing) { + return ( + +
+
+

{t("General")}

+ + +
+ +
+
+ + {error && ( + {error} + )} + + + +
+ ); + } + + // Creating a new integration + return ( + +
+
+

{t("General")}

+ + +
+ + {error && ( + {error} + )} + +
+ + +
+
+
+ ); +}; + +const CopyButton = ({ value }: { value: string }) => { + const { t } = useTranslation(); + return ( + + + + )} + + +

{t("Incoming mail (IMAP)")}

+
+
+
+
{t("Server")}
+
+ {imapHost} + +
+
+
+
{t("Port")}
+
{imapPort}
+
+
+
{t("Security")}
+
{imapSecurity}
+
+
+
+

{t("Outgoing mail (SMTP)")}

+
+
+
+
{t("Server")}
+
+ {smtpHost} + +
+
+
+
{t("Port")}
+
{smtpPort}
+
+
+
{t("Security")}
+
{smtpSecurity}
+
+
+
+ + ); +}; diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/index.tsx index 7587db3f7..5ea548f3c 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/index.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { useState, useEffect } from "react"; import { Channel } from "@/features/api/gen"; import { WidgetIntegrationForm } from "./widget-integration-form"; +import { ClientBridgeIntegrationForm } from "./client-bridge-integration-form"; import { useConfig } from "@/features/providers/config"; import i18n from "@/features/i18n/initI18n"; @@ -14,7 +15,7 @@ type ModalComposeIntegrationProps = { onSuccess?: () => void; }; -type ChannelType = "widget" | "api_key"; +type ChannelType = "widget" | "api_key" | "client-bridge"; type ViewState = "select_type" | "form"; type ChannelTypeCardProps = { @@ -47,6 +48,12 @@ const CHANNEL_TYPE_METADATA: Record = { icon: "key", disabled: true }, + "client-bridge": { + type: "client-bridge", + title: i18n.t("Email client access"), + description: i18n.t("Connect your mailbox to an email client like Thunderbird or to your mobile phone using IMAP and SMTP."), + icon: "mail_lock", + }, }; const ChannelTypeCard = ({ title, description, icon, disabled, onClick }: ChannelTypeCardProps) => { @@ -142,6 +149,9 @@ export const ModalComposeIntegration = ({ if (selectedType === "widget") { return isEditing ? t("Edit Widget") : t("Create a Widget"); } + if (selectedType === "client-bridge") { + return isEditing ? t("Edit email client access") : t("Create email client access"); + } return t("Integrations"); }; @@ -188,6 +198,13 @@ export const ModalComposeIntegration = ({ onClose={onClose} /> )} + {viewState === "form" && selectedType === "client-bridge" && ( + + )} ); diff --git a/src/frontend/src/features/providers/config.tsx b/src/frontend/src/features/providers/config.tsx index d038a9752..aa76798e6 100644 --- a/src/frontend/src/features/providers/config.tsx +++ b/src/frontend/src/features/providers/config.tsx @@ -33,6 +33,7 @@ const DEFAULT_CONFIG: AppConfig = { DRIVE: DEFAULT_DRIVE_CONFIG, MESSAGES_MANUAL_RETRY_MAX_AGE: 0, FRONTEND_SILENT_LOGIN_ENABLED: false, + CLIENTBRIDGE_PUBLIC_CONFIG: undefined, } const ConfigContext = createContext(DEFAULT_CONFIG)