diff --git a/.gitignore b/.gitignore index 7826eb28..e0d83beb 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ src/frontend/out/ tsconfig.tsbuildinfo src/frontend/.config src/frontend/.next +.vite # Mails src/backend/core/templates/mail/ @@ -87,3 +88,6 @@ CLAUDE.md # Various .turbo + +# Messages +.aws diff --git a/Makefile b/Makefile index 1f4d5cd2..5b7ece32 100644 --- a/Makefile +++ b/Makefile @@ -27,12 +27,6 @@ BOLD := \033[1m RESET := \033[0m GREEN := \033[1;32m - -# -- Database - -DB_HOST = postgresql -DB_PORT = 5432 - # -- Docker # Get the current user ID to use for docker run and docker exec commands DOCKER_UID = $(shell id -u) @@ -74,7 +68,8 @@ create-env-files: \ env.d/development/frontend.local \ env.d/development/mta-in.local \ env.d/development/mta-out.local \ - env.d/development/socks-proxy.local + env.d/development/socks-proxy.local \ + env.d/development/widgets.local .PHONY: create-env-files bootstrap: ## Prepare the project for local development @@ -118,6 +113,7 @@ update: ## Update the project with latest changes @$(MAKE) collectstatic @$(MAKE) migrate @$(MAKE) front-install-frozen + @$(MAKE) widgets-install # @$(MAKE) back-i18n-compile .PHONY: update @@ -245,7 +241,7 @@ front-test: ## run the frontend tests front-test-amd64: ## run the frontend tests in amd64 @$(COMPOSE) run --rm frontend-tools-amd64 npm run test -.PHONY: front-test +.PHONY: front-test-amd64 mta-in-test: ## run the mta-in tests @$(COMPOSE) run --build --rm mta-in-test @@ -420,30 +416,30 @@ help: .PHONY: help front-shell: ## open a shell in the frontend container - @$(COMPOSE) run --rm frontend-tools /bin/sh + @$(COMPOSE) run --rm --build frontend-tools /bin/sh .PHONY: front-shell # Front front-install: ## install the frontend locally @args="$(filter-out $@,$(MAKECMDGOALS))" && \ - $(COMPOSE) run --rm frontend-tools npm install $${args:-${1}} + $(COMPOSE) run --rm --build frontend-tools npm install $${args:-${1}} .PHONY: front-install front-install-frozen: ## install the frontend locally, following the frozen lockfile @echo "Installing frontend dependencies, this might take a few minutes..." - @$(COMPOSE) run --rm frontend-tools npm ci + @$(COMPOSE) run --rm --build frontend-tools npm ci .PHONY: front-install-frozen front-install-frozen-amd64: ## install the frontend locally, following the frozen lockfile - @$(COMPOSE) run --rm frontend-tools-amd64 npm ci + @$(COMPOSE) run --rm --build frontend-tools-amd64 npm ci .PHONY: front-install-frozen-amd64 front-build: ## build the frontend locally - @$(COMPOSE) run --rm frontend-tools npm run build + @$(COMPOSE) run --rm --build frontend-tools npm run build .PHONY: front-build front-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin - @$(COMPOSE) run --rm frontend-tools npm run i18n:extract + @$(COMPOSE) run --rm --build frontend-tools npm run i18n:extract .PHONY: front-i18n-extract front-i18n-generate: ## Generate the frontend json files used for crowdin @@ -451,8 +447,8 @@ front-i18n-generate: ## Generate the frontend json files used for crowdin front-i18n-extract .PHONY: front-i18n-generate -front-i18n-compile: ## Format the crowin json files used deploy to the apps - @$(COMPOSE) run --rm frontend-tools npm run i18n:deploy +front-i18n-compile: ## Format the crowdin json files used deploy to the apps + @$(COMPOSE) run --rm --build frontend-tools npm run i18n:deploy .PHONY: front-i18n-compile back-api-update: ## Update the OpenAPI schema @@ -460,9 +456,41 @@ back-api-update: ## Update the OpenAPI schema .PHONY: back-api-update front-api-update: ## Update the frontend API client - @$(COMPOSE) run --rm frontend-tools npm run api:update + @$(COMPOSE) run --rm --build frontend-tools npm run api:update .PHONY: front-api-update +# Widgets +widgets-install: ## install the widgets locally + @args="$(filter-out $@,$(MAKECMDGOALS))" && \ + $(COMPOSE) run --build --rm widgets-dev npm install $${args:-${1}} +.PHONY: widgets-install + +widgets-freeze-deps: ## freeze the widgets dependencies + rm -rf src/widgets/package-lock.json + @$(MAKE) widgets-install +.PHONY: widgets-freeze-deps + +widgets-build: ## build the widgets + $(COMPOSE) run --build --rm widgets-dev npm run build +.PHONY: widgets-build + +widgets-shell: ## open a shell in the widgets container + $(COMPOSE) run --build --rm widgets-dev /bin/sh +.PHONY: widgets-shell + +widgets-start: ## start the widgets container + $(COMPOSE) up --force-recreate --build -d widgets-dev --wait +.PHONY: widgets-start + +widgets-deploy: ## deploy the widgets to an S3 bucket + @## Error if the env vars MESSAGES_WIDGETS_S3_PATH is not set + @if [ -z "$$MESSAGES_WIDGETS_S3_PATH" ]; then \ + echo "Error: MESSAGES_WIDGETS_S3_PATH is not set"; \ + exit 1; \ + fi; \ + docker run --rm -ti -v .aws:/root/.aws -v `pwd`/src/widgets/dist:/aws amazon/aws-cli s3 cp --acl public-read --recursive . s3://$(MESSAGES_WIDGETS_S3_PATH) +.PHONY: widgets-deploy + api-update: ## Update the OpenAPI schema then frontend API client api-update: \ back-api-update \ diff --git a/README.md b/README.md index 4cb58080..24438464 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ When running the project, the following services are available: | **Keycloak** | [http://localhost:8902](http://localhost:8902) | Identity provider admin | `admin` / `admin` | | **Celery UI** | [http://localhost:8903](http://localhost:8903) | Task queue monitoring | No auth required | | **Mailcatcher** | [http://localhost:8904](http://localhost:8904) | Email testing interface | No auth required | +| **Widgets** | [http://localhost:8905](http://localhost:8905) | Widgets development server | No auth required | | **MTA-in (SMTP)** | 8910 | Incoming email server | No auth required | | **MTA-out (SMTP)** | 8911 | Outgoing email server | `user` / `pass` | | **PostgreSQL** | 8912 | Database server | `user` / `pass` | @@ -201,6 +202,27 @@ MTA_OUT_MODE=relay MTA_OUT_RELAY_HOST=mailcatcher:1025 python manage.py send_mai > ⚠️ Most residential ISPs block the outgoing port 25, so you might not be able to send emails to outside > servers from your localhost. This is why the mailcatcher is so useful locally. +### Developing Widgets + +We currently develop some embeddable widgets in the `src/widgets` directory in this repository. + +``` +$ make widgets-start +``` + +This will start the development server at [http://localhost:8905](http://localhost:8905). + +You can then build them with: + +``` +$ make widgets-build +``` + +And deploy them to an S3 bucket, with `.aws/{config|credentials}` files in the root of the repository. +``` +$ MESSAGES_WIDGETS_S3_PATH=xxx make widgets-deploy +``` + ## Feedback 🙋‍♂️🙋‍♀️ We'd love to hear your thoughts, and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#messages-official:matrix.org). diff --git a/compose.yaml b/compose.yaml index 1255f77e..d66667c7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -217,7 +217,7 @@ services: user: "${DOCKER_USER:-1000}" build: context: ./src/frontend - dockerfile: Dockerfile.dev + dockerfile: Dockerfile env_file: - env.d/development/frontend.defaults - env.d/development/frontend.local @@ -232,7 +232,8 @@ services: profiles: - frontend-tools build: - dockerfile: ./src/frontend/Dockerfile.dev + context: ./src/frontend + dockerfile: Dockerfile volumes: - ./src/backend/core/api/openapi.json:/home/backend/core/api/openapi.json - ./src/frontend/:/home/frontend/ @@ -243,11 +244,28 @@ services: - frontend-tools platform: linux/amd64 build: - dockerfile: ./src/frontend/Dockerfile.dev + context: ./src/frontend + dockerfile: Dockerfile volumes: - ./src/backend/core/api/openapi.json:/home/backend/core/api/openapi.json - ./src/frontend/:/home/frontend/ + + widgets-dev: + user: "${DOCKER_USER:-1000}" + build: + context: ./src/widgets + dockerfile: Dockerfile + env_file: + - env.d/development/widgets.defaults + - env.d/development/widgets.local + command: ["npm", "run", "dev"] + volumes: + - ./src/widgets/:/home/widgets/ + ports: + - "8905:8905" + + # crowdin: # image: crowdin/cli:3.16.0 # volumes: diff --git a/docs/env.md b/docs/env.md index e10e6c7b..cad18e4f 100644 --- a/docs/env.md +++ b/docs/env.md @@ -191,14 +191,6 @@ The application uses a new environment file structure with `.defaults` and `.loc |----------|---------|-------------|----------| | `SENTRY_DSN` | None | Sentry DSN for error tracking | Optional | -### PostHog - -| Variable | Default | Description | Required | -|----------|---------|-------------|----------| -| `POSTHOG_KEY` | None | PostHog analytics key | Optional | -| `POSTHOG_HOST` | `https://eu.i.posthog.com` | PostHog analytics host url | Optional | -| `POSTHOG_SURVEY_ID` | None | PostHog survey id to get feedback from users | Optional | - ### Logging | Variable | Default | Description | Required | diff --git a/env.d/development/frontend.defaults b/env.d/development/frontend.defaults index d483d21d..9622bf4a 100644 --- a/env.d/development/frontend.defaults +++ b/env.d/development/frontend.defaults @@ -1,3 +1,6 @@ NEXT_PUBLIC_API_ORIGIN=http://localhost:8901 NEXT_PUBLIC_S3_DOMAIN_REPLACE=http://localhost:9000 -NEXT_TELEMETRY_DISABLED=1 \ No newline at end of file +NEXT_TELEMETRY_DISABLED=1 +NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL=http://localhost:8901/api/v1.0/inbound/widget/ +NEXT_PUBLIC_FEEDBACK_WIDGET_PATH=http://localhost:8905/dist/ +NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL= \ No newline at end of file diff --git a/env.d/development/widgets.defaults b/env.d/development/widgets.defaults new file mode 100644 index 00000000..2ce9fcd8 --- /dev/null +++ b/env.d/development/widgets.defaults @@ -0,0 +1 @@ +# Nothing yet! \ No newline at end of file diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index e9ba7907..4e71f90d 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -183,6 +183,32 @@ class MailboxAdmin(admin.ModelAdmin): ) +@admin.register(models.Channel) +class ChannelAdmin(admin.ModelAdmin): + """Admin class for the Channel model""" + + list_display = ("name", "type", "mailbox", "maildomain", "created_at") + list_filter = ("type", "created_at") + search_fields = ("name", "type") + readonly_fields = ("created_at", "updated_at") + autocomplete_fields = ("mailbox", "maildomain") + + fieldsets = ( + (None, {"fields": ("name", "type", "settings")}), + ( + "Target", + { + "fields": ("mailbox", "maildomain"), + "description": "Specify either a mailbox or maildomain, but not both.", + }, + ), + ( + "Timestamps", + {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}, + ), + ) + + @admin.register(models.MailboxAccess) class MailboxAccessAdmin(admin.ModelAdmin): """Admin class for the MailboxAccess model""" @@ -354,7 +380,7 @@ class MessageAdmin(admin.ModelAdmin): ) search_fields = ("subject", "sender__name", "sender__email", "mime_id") change_list_template = "admin/core/message/change_list.html" - raw_id_fields = ("thread", "blob", "draft_blob", "parent") + raw_id_fields = ("thread", "blob", "draft_blob", "parent", "channel") autocomplete_fields = ("sender",) readonly_fields = ("mime_id", "created_at", "updated_at") diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index c7d27d21..7880f21d 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -148,21 +148,6 @@ "type": "string", "readOnly": true }, - "POSTHOG_KEY": { - "type": "string", - "nullable": true, - "readOnly": true - }, - "POSTHOG_HOST": { - "type": "string", - "nullable": true, - "readOnly": true - }, - "POSTHOG_SURVEY_ID": { - "type": "string", - "nullable": true, - "readOnly": true - }, "LANGUAGES": { "type": "array", "items": { @@ -216,9 +201,6 @@ }, "required": [ "ENVIRONMENT", - "POSTHOG_KEY", - "POSTHOG_HOST", - "POSTHOG_SURVEY_ID", "LANGUAGES", "LANGUAGE_CODE", "AI_ENABLED", @@ -3079,34 +3061,6 @@ } } }, - "/api/v1.0/mta/check-recipients/": { - "post": { - "operationId": "mta_check_recipients_create", - "description": "Check if recipient email addresses exist for the MTA.", - "tags": [ - "mta" - ], - "responses": { - "200": { - "description": "No response body" - } - } - } - }, - "/api/v1.0/mta/inbound-email/": { - "post": { - "operationId": "mta_inbound_email_create", - "description": "Handle incoming raw email (message/rfc822) from MTA.", - "tags": [ - "mta" - ], - "responses": { - "200": { - "description": "No response body" - } - } - } - }, "/api/v1.0/placeholders/": { "get": { "operationId": "placeholders_retrieve", diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index d9a0db24..73cedfc9 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1139,3 +1139,39 @@ class ImportIMAPSerializer(ImportBaseSerializer): use_ssl = serializers.BooleanField( help_text="Use SSL for IMAP connection", required=False, default=True ) + + +class ChannelSerializer(AbilitiesModelSerializer): + """Serialize Channel model.""" + + class Meta: + model = models.Channel + fields = [ + "id", + "name", + "type", + "settings", + "mailbox", + "maildomain", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + def validate(self, attrs): + """Validate channel data.""" + mailbox = attrs.get("mailbox") + maildomain = attrs.get("maildomain") + + # Validate that either mailbox or maildomain is set, but not both + if not mailbox and not maildomain: + raise serializers.ValidationError( + "Either mailbox or maildomain must be specified." + ) + + if mailbox and maildomain: + raise serializers.ValidationError( + "Cannot specify both mailbox and maildomain." + ) + + return attrs diff --git a/src/backend/core/api/viewsets/config.py b/src/backend/core/api/viewsets/config.py index 9e6c62c7..fe0fafe2 100644 --- a/src/backend/core/api/viewsets/config.py +++ b/src/backend/core/api/viewsets/config.py @@ -23,21 +23,6 @@ class ConfigView(drf.views.APIView): "type": "object", "properties": { "ENVIRONMENT": {"type": "string", "readOnly": True}, - "POSTHOG_KEY": { - "type": "string", - "nullable": True, - "readOnly": True, - }, - "POSTHOG_HOST": { - "type": "string", - "nullable": True, - "readOnly": True, - }, - "POSTHOG_SURVEY_ID": { - "type": "string", - "nullable": True, - "readOnly": True, - }, "LANGUAGES": { "type": "array", "items": {"type": "string"}, @@ -80,9 +65,6 @@ class ConfigView(drf.views.APIView): }, "required": [ "ENVIRONMENT", - "POSTHOG_KEY", - "POSTHOG_HOST", - "POSTHOG_SURVEY_ID", "LANGUAGES", "LANGUAGE_CODE", "AI_ENABLED", @@ -103,9 +85,6 @@ class ConfigView(drf.views.APIView): """ array_settings = [ "ENVIRONMENT", - "POSTHOG_KEY", - "POSTHOG_HOST", - "POSTHOG_SURVEY_ID", "LANGUAGES", "LANGUAGE_CODE", "SCHEMA_CUSTOM_ATTRIBUTES_USER", diff --git a/src/backend/core/api/viewsets/flag.py b/src/backend/core/api/viewsets/flag.py index 28831721..b0733e43 100644 --- a/src/backend/core/api/viewsets/flag.py +++ b/src/backend/core/api/viewsets/flag.py @@ -21,7 +21,7 @@ from .. import permissions ALLOWED_FLAGS = ["unread", "starred", "trashed"] -class ChangeFlagViewSet(APIView): +class ChangeFlagView(APIView): """ViewSet for changing flags on messages or threads.""" permission_classes = [permissions.IsAllowedToAccess] diff --git a/src/backend/core/api/viewsets/inbound/__init__.py b/src/backend/core/api/viewsets/inbound/__init__.py new file mode 100644 index 00000000..31d1201f --- /dev/null +++ b/src/backend/core/api/viewsets/inbound/__init__.py @@ -0,0 +1,26 @@ +"""Channel management module for handling different message sources.""" + +# from typing import Dict, Type, Optional +# from core import models +# from core.channels.widget import WidgetChannel +# from core.channels.mta import MTAChannel + + +# # Registry of available channel types +# CHANNEL_TYPES: Dict[str, Type] = { +# "widget": WidgetChannel, +# "mta": MTAChannel, +# } + + +# def list_channel_types() -> Dict[str, str]: +# """Return a dictionary of available channel types with their descriptions.""" +# return { +# channel_type: processor_class.DESCRIPTION +# for channel_type, processor_class in CHANNEL_TYPES.items() +# } + + +# def load_channel(channel_type: str) -> Optional[Type]: +# """Load a channel processor class by type.""" +# return CHANNEL_TYPES.get(channel_type) diff --git a/src/backend/core/api/viewsets/mta.py b/src/backend/core/api/viewsets/inbound/mta.py similarity index 79% rename from src/backend/core/api/viewsets/mta.py rename to src/backend/core/api/viewsets/inbound/mta.py index 5a6ba04f..05acf864 100644 --- a/src/backend/core/api/viewsets/mta.py +++ b/src/backend/core/api/viewsets/inbound/mta.py @@ -1,41 +1,39 @@ -"""DRF Views for MTA endpoints""" +"""MTA channel implementation for handling email delivery.""" import hashlib import logging import secrets from django.conf import settings -from django.contrib.auth import get_user_model import jwt -import rest_framework as drf -from rest_framework import authentication, parsers, status, viewsets +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.mda.inbound import check_local_recipient, deliver_inbound_message from core.mda.rfc5322 import EmailParseError, parse_email_message logger = logging.getLogger(__name__) -User = get_user_model() - -class MTAJWTAuthentication(authentication.BaseAuthentication): +class MTAJWTAuthentication(BaseAuthentication): """ Custom authentication for MTA endpoints using JWT tokens with email hash validation. Returns None or (user, auth) """ def authenticate(self, request): - # Get the auth header auth_header = request.headers.get("Authorization") if not auth_header: return None try: - # Extract and validate JWT jwt_token = auth_header.split(" ")[1] payload = jwt.decode( jwt_token, @@ -48,68 +46,67 @@ class MTAJWTAuthentication(authentication.BaseAuthentication): }, ) + 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") - service_account = User() + service_account = models.User() return (service_account, payload) except (jwt.ExpiredSignatureError, jwt.InvalidTokenError) as e: - raise drf.exceptions.AuthenticationFailed("Invalid token") from e + raise AuthenticationFailed("Invalid token") from e except (IndexError, KeyError) as e: - # Handle cases where header is malformed or payload is missing keys - raise drf.exceptions.AuthenticationFailed( - "Invalid token header or payload" - ) from e + raise AuthenticationFailed("Invalid token header or payload") from e def authenticate_header(self, request): """Return the header to be used in the WWW-Authenticate response header.""" return 'Bearer realm="MTA"' -class MTAViewSet(viewsets.GenericViewSet): - """ViewSet for MTA-related endpoints""" +class InboundMTAViewSet(viewsets.GenericViewSet): + """Handles incoming email messages from MTA (Mail Transfer Agent).""" + + # Channel metadata + CHANNEL_TYPE = "mta" + CHANNEL_DESCRIPTION = "Mail Transfer Agent (email)" permission_classes = [IsAuthenticated] authentication_classes = [MTAJWTAuthentication] + @extend_schema(exclude=True) @action( - detail=False, - methods=["post"], - url_path="check-recipients", - parser_classes=[parsers.JSONParser], + detail=False, methods=["post"], url_path="check", url_name="inbound-mta-check" ) - def check_recipients(self, request): - """Check if recipient email addresses exist for the MTA.""" - # Get a list of email addresses from the request body - email_addresses = request.data.get("addresses") - if not email_addresses or not isinstance(email_addresses, list): + def check(self, request): + """Check recipients exist.""" + data = request.data + addresses = data.get("addresses", []) + if not addresses or not isinstance(addresses, list): return Response( {"detail": "Missing addresses"}, status=status.HTTP_400_BAD_REQUEST ) - # Check if each address is locally deliverable - ret = { - email_address: check_local_recipient(email_address, create_if_missing=False) - for email_address in email_addresses + results = { + address: check_local_recipient(address, create_if_missing=False) + for address in addresses } + return Response(results) - return Response(ret) - + @extend_schema(exclude=True) @action( detail=False, methods=["post"], - url_path="inbound-email", - # Use ByteParser to handle raw message/rfc822 directly - parser_classes=[parsers.BaseParser], # Keep BaseParser if JWT needs body hash + url_path="deliver", + url_name="inbound-mta-deliver", ) - def inbound_email(self, request): + def deliver(self, request): """Handle incoming raw email (message/rfc822) from MTA.""" - # Authentication is handled by MTAJWTAuthentication # request.user will be the service account, request.auth the JWT payload mta_metadata = request.auth if not mta_metadata or "original_recipients" not in mta_metadata: diff --git a/src/backend/core/api/viewsets/inbound/widget.py b/src/backend/core/api/viewsets/inbound/widget.py new file mode 100644 index 00000000..02e7c2e2 --- /dev/null +++ b/src/backend/core/api/viewsets/inbound/widget.py @@ -0,0 +1,193 @@ +"""Widget channel implementation for receiving messages from web widgets.""" + +import logging +from html import escape as html_escape + +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.utils import timezone + +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.response import Response + +from core import models +from core.api.permissions import IsAuthenticated +from core.mda.inbound import deliver_inbound_message +from core.mda.rfc5322 import compose_email + +logger = logging.getLogger(__name__) + + +class WidgetAuthentication(BaseAuthentication): + """ + Custom authentication for widget endpoints using channel_id header + Returns None or (user, auth) + """ + + def authenticate(self, request): + # Try API key authentication first + channel_id = request.headers.get("X-Channel-ID") + if not channel_id: + raise AuthenticationFailed("Missing channel_id") + + # API key authentication for check endpoint + try: + channel = models.Channel.objects.get(id=channel_id) + except models.Channel.DoesNotExist as e: + raise AuthenticationFailed("Invalid channel_id") from e + + return (None, {"channel": channel}) + + +class InboundWidgetViewSet(viewsets.GenericViewSet): + """Handles incoming messages from web widgets.""" + + # Channel metadata + CHANNEL_TYPE = "widget" + CHANNEL_DESCRIPTION = "Web widgets and forms" + + permission_classes = [IsAuthenticated] + authentication_classes = [WidgetAuthentication] + + @extend_schema(exclude=True) + @action( + detail=False, + methods=["get"], + url_path="config", + url_name="inbound-widget-config", + ) + def config(self, request): + """Return the configuration for the widget.""" + + auth_data = request.auth + channel = auth_data["channel"] + + return Response( + {"success": True, "config": (channel.settings or {}).get("config") or {}} + ) + + @extend_schema(exclude=True) + @action( + detail=False, + methods=["post"], + url_path="deliver", + url_name="inbound-widget-deliver", + ) + def deliver(self, request): + """Handle incoming widget message.""" + + # TODO: throttle + + data = request.data + auth_data = request.auth + channel = auth_data["channel"] + + unverified_sender_email = data.get("email") + message_text = data.get("textBody", "") + + if not unverified_sender_email: + return Response( + {"detail": "Missing email"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Validate the sender email format with django's email validator + try: + validate_email(unverified_sender_email) + except ValidationError: + return Response( + {"detail": "Invalid email format"}, status=status.HTTP_400_BAD_REQUEST + ) + + if not message_text: + return Response( + {"detail": "Missing message"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Get the target mailbox + mailbox = channel.mailbox + if not mailbox: + return Response( + {"detail": "No mailbox configured for this channel"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + if mailbox.contact: + target_email = mailbox.contact.email + target_name = mailbox.contact.name + else: + target_email = str(mailbox) + target_name = str(mailbox) + + default_sender_email = ( + channel.settings.get("default_sender_email") or "widget@noreply.invalid" + ) + default_sender_name = channel.settings.get("default_sender_name") or "Widget" + + # Once we have means to authenticate senders (JWT?) we'll set them here. + # For now, we use a default sender configured in the channel. + sender_email = default_sender_email + sender_name = default_sender_name + + intro_text = ( + channel.settings.get("intro_text") + or "The following message was received from a widget:" + ) + + escaped_email = html_escape(unverified_sender_email) + signature = [ + ( + "Sender", + f"{escaped_email} (❌ Unverified)", + ), + ("IP", request.META.get("REMOTE_ADDR")), # TODO geoip + ( + "Page", + ( + f"" + + f"{html_escape(request.META.get('HTTP_REFERER'))}" + ), + ), + ] + + message_text = ( + intro_text + + "

" + + html_escape(message_text).replace("\n", "
") + + "

--
" + + "
".join([f"{k}: {v}" for k, v in signature]) + ) + + # Build a JMAP-like structured format that we could have got from parse_email_message() + parsed_email = { + "subject": f"Message from {unverified_sender_email}", + "from": {"name": sender_name, "email": sender_email}, + "to": [{"name": target_name, "email": target_email}], + "date": timezone.now(), + "htmlBody": [{"content": message_text}], + } + + delivered = deliver_inbound_message( + target_email, parsed_email, compose_email(parsed_email), channel=channel + ) + + if not delivered: + return Response( + {"detail": "Failed to deliver message"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + logger.info( + "Successfully created message from widget for channel %s, sender: %s", + channel.id, + unverified_sender_email, + ) + + return Response( + { + "success": True, + } + ) diff --git a/src/backend/core/authentication/__init__.py b/src/backend/core/authentication/__init__.py index 17c6595a..3b34c95b 100644 --- a/src/backend/core/authentication/__init__.py +++ b/src/backend/core/authentication/__init__.py @@ -1,52 +1 @@ """Custom authentication classes for the messages core app""" - -from django.conf import settings - -from rest_framework.authentication import BaseAuthentication -from rest_framework.exceptions import AuthenticationFailed - - -class ServerToServerAuthentication(BaseAuthentication): - """ - Custom authentication class for server-to-server requests. - Validates the presence and correctness of the Authorization header. - """ - - AUTH_HEADER = "Authorization" - TOKEN_TYPE = "Bearer" # noqa S105 - - def authenticate(self, request): - """ - Authenticate the server-to-server request by validating the Authorization header. - - This method checks if the Authorization header is present in the request, ensures it - contains a valid token with the correct format, and verifies the token against the - list of allowed server-to-server tokens. If the header is missing, improperly formatted, - or contains an invalid token, an AuthenticationFailed exception is raised. - - Returns: - None: If authentication is successful - (no user is authenticated for server-to-server requests). - - Raises: - AuthenticationFailed: If the Authorization header is missing, malformed, - or contains an invalid token. - """ - auth_header = request.headers.get(self.AUTH_HEADER) - if not auth_header: - raise AuthenticationFailed("Authorization header is missing.") - - # Validate token format and existence - auth_parts = auth_header.split(" ") - if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE: - raise AuthenticationFailed("Invalid authorization header.") - - token = auth_parts[1] - if token not in settings.SERVER_TO_SERVER_API_TOKENS: - raise AuthenticationFailed("Invalid server-to-server token.") - - # Authentication is successful, but no user is authenticated - - def authenticate_header(self, request): - """Return the WWW-Authenticate header value.""" - return f"{self.TOKEN_TYPE} realm='Create item server to server'" diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 3777a3ec..d27107fc 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -256,3 +256,15 @@ class AttachmentFactory(factory.django.DjangoModelFactory): kwargs = dict(kwargs) kwargs.pop("blob_size", None) return kwargs + + +class ChannelFactory(factory.django.DjangoModelFactory): + """A factory to create channels for testing purposes.""" + + class Meta: + model = models.Channel + + name = factory.Sequence(lambda n: f"Test Channel {n}") + type = factory.fuzzy.FuzzyChoice(["widget", "mta"]) + settings = factory.Dict({"config": {"enabled": True}}) + mailbox = factory.SubFactory(MailboxFactory) diff --git a/src/backend/core/mda/inbound.py b/src/backend/core/mda/inbound.py index 195f456e..33f41143 100644 --- a/src/backend/core/mda/inbound.py +++ b/src/backend/core/mda/inbound.py @@ -336,6 +336,7 @@ def deliver_inbound_message( # pylint: disable=too-many-branches, too-many-stat is_import: bool = False, imap_labels: Optional[List[str]] = None, imap_flags: Optional[List[str]] = None, + channel: Optional[models.Channel] = None, ) -> bool: # Return True on success, False on failure """Deliver a parsed inbound email message to the correct mailbox and thread. @@ -556,6 +557,7 @@ def deliver_inbound_message( # pylint: disable=too-many-branches, too-many-stat is_trashed=False, is_unread=True, has_attachments=len(parsed_email.get("attachments", [])) > 0, + channel=channel, ) if is_import: # We need to set the created_at field to the date of the message diff --git a/src/backend/core/middlewares.py b/src/backend/core/middlewares.py index 0d340836..b81a7ad2 100644 --- a/src/backend/core/middlewares.py +++ b/src/backend/core/middlewares.py @@ -1,9 +1,14 @@ -"""Custom Django middlewares""" +""" +Custom middleware for the messages application. +""" from secrets import compare_digest from django.conf import settings from django.http import HttpResponse +from django.utils.cache import patch_vary_headers + +from corsheaders.middleware import CorsMiddleware class PrometheusAuthMiddleware: @@ -24,3 +29,102 @@ class PrometheusAuthMiddleware: return HttpResponse("Unauthorized", status=401) return self.get_response(request) + + +class CustomCorsMiddleware(CorsMiddleware): + """ + Custom CORS middleware that allows all origins for specific API paths. + + This middleware extends the default CORS middleware to allow all origins + for paths matching /api/{version}/inbound/widget/* while maintaining the + existing CORS configuration for all other paths. + """ + + def _get_cors_headers_for_widget_api(self, request): + """ + Get CORS headers for widget API requests - allows all origins, headers, and methods. + + Args: + request: The Django request object + + Returns: + dict: CORS headers for widget API + """ + origin = request.META.get("HTTP_ORIGIN", "*") + + headers = { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "false", + "Vary": "Origin, Content-Type, X-Channel-ID", + } + + # Add preflight headers for OPTIONS requests + if request.method == "OPTIONS": + headers.update( + { + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, X-Channel-ID", + "Access-Control-Max-Age": "86400", # 24 hours + } + ) + + return headers + + def __call__(self, request): + """ + Process the request and response with custom CORS handling for widget API. + """ + + def update_headers(response, headers): + """Update the response headers with the given headers.""" + for header, value in headers.items(): + if header.lower() == "vary": + patch_vary_headers(response, [h.strip() for h in value.split(",")]) + else: + response[header] = value + + if request.path.startswith(f"/api/{settings.API_VERSION}/inbound/widget/"): + # Handle CORS for widget API requests manually + if request.method == "OPTIONS": + # Handle preflight requests + headers = self._get_cors_headers_for_widget_api(request) + response = HttpResponse(status=200) + update_headers(response, headers) + return response + + # Process the request normally + response = self.get_response(request) + + # Add CORS headers to the response + headers = self._get_cors_headers_for_widget_api(request) + update_headers(response, headers) + + return response + + # Use default CORS behavior for all other paths + return super().__call__(request) + + +class XForwardedForMiddleware: + """ + Middleware that sets the REMOTE_ADDR from the X-Forwarded-For header if present. + + Note: This middleware is only enabled if USE_X_FORWARDED_FOR is True (default is False), because + it's not safe to use in production if the headers are not trusted (safely overridden by a proxy). + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + try: + real_ip = request.META["HTTP_X_FORWARDED_FOR"] + except KeyError: + pass + else: + # HTTP_X_FORWARDED_FOR can be a comma-separated list of IPs. The + # client's IP will be the first one. + real_ip = real_ip.split(",")[0].strip() + request.META["REMOTE_ADDR"] = real_ip + + return self.get_response(request) diff --git a/src/backend/core/migrations/0008_channel_message_channel_channel_channel_has_target.py b/src/backend/core/migrations/0008_channel_message_channel_channel_channel_has_target.py new file mode 100644 index 00000000..7bc0488e --- /dev/null +++ b/src/backend/core/migrations/0008_channel_message_channel_channel_channel_has_target.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.12 on 2025-09-24 09:28 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0007_blob_size_compressed'), + ] + + operations = [ + migrations.CreateModel( + name='Channel', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('name', models.CharField(help_text='Human-readable name for this channel', max_length=255, verbose_name='name')), + ('type', models.CharField(default='mta', help_text='Type of channel', max_length=255, verbose_name='type')), + ('settings', models.JSONField(blank=True, default=dict, help_text='Channel-specific configuration settings', verbose_name='settings')), + ('mailbox', models.ForeignKey(blank=True, help_text='Mailbox that receives messages from this channel', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='channels', to='core.mailbox')), + ('maildomain', models.ForeignKey(blank=True, help_text='Mail domain that owns this channel', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='channels', to='core.maildomain')), + ], + options={ + 'verbose_name': 'channel', + 'verbose_name_plural': 'channels', + 'db_table': 'messages_channel', + 'ordering': ['-created_at'], + }, + ), + migrations.AddField( + model_name='message', + name='channel', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='messages', to='core.channel'), + ), + migrations.AddConstraint( + model_name='channel', + constraint=models.CheckConstraint(condition=models.Q(('mailbox__isnull', False), ('maildomain__isnull', False), _connector='XOR'), name='channel_has_target'), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index e02c8ea3..7e78001a 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -411,6 +411,60 @@ class MailDomain(BaseModel): ) +class Channel(BaseModel): + """Channel model to store channel information for receiving messages from various sources.""" + + name = models.CharField( + _("name"), max_length=255, help_text=_("Human-readable name for this channel") + ) + + type = models.CharField( + _("type"), max_length=255, help_text=_("Type of channel"), default="mta" + ) + + settings = models.JSONField( + _("settings"), + default=dict, + blank=True, + help_text=_("Channel-specific configuration settings"), + ) + + mailbox = models.ForeignKey( + "Mailbox", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="channels", + help_text=_("Mailbox that receives messages from this channel"), + ) + + maildomain = models.ForeignKey( + "MailDomain", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="channels", + help_text=_("Mail domain that owns this channel"), + ) + + class Meta: + db_table = "messages_channel" + verbose_name = _("channel") + verbose_name_plural = _("channels") + ordering = ["-created_at"] + constraints = [ + models.CheckConstraint( + check=( + models.Q(mailbox__isnull=False) ^ models.Q(maildomain__isnull=False) + ), + name="channel_has_target", + ), + ] + + def __str__(self): + return self.name + + class Mailbox(BaseModel): """Mailbox model to store mailbox information.""" @@ -1126,6 +1180,14 @@ class Message(BaseModel): mime_id = models.CharField(_("mime id"), max_length=998, null=True, blank=True) + channel = models.ForeignKey( + "Channel", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="messages", + ) + # Stores the raw MIME message. blob = models.ForeignKey( "Blob", diff --git a/src/backend/core/tests/api/test_config.py b/src/backend/core/tests/api/test_config.py index 87174127..59518e75 100644 --- a/src/backend/core/tests/api/test_config.py +++ b/src/backend/core/tests/api/test_config.py @@ -16,9 +16,6 @@ pytestmark = pytest.mark.django_db @override_settings( - POSTHOG_KEY="132456", - POSTHOG_HOST="https://test.i.posthog-test.com", - POSTHOG_SURVEY_ID="7890", LANGUAGES=[["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]], LANGUAGE_CODE="en-us", AI_API_KEY=None, @@ -43,9 +40,6 @@ def test_api_config(is_authenticated): "ENVIRONMENT": "test", "LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]], "LANGUAGE_CODE": "en-us", - "POSTHOG_KEY": "132456", - "POSTHOG_HOST": "https://test.i.posthog-test.com", - "POSTHOG_SURVEY_ID": "7890", "AI_ENABLED": False, "AI_FEATURE_SUMMARY_ENABLED": False, "AI_FEATURE_AUTOLABELS_ENABLED": False, diff --git a/src/backend/core/tests/api/test_mta.py b/src/backend/core/tests/api/test_inbound_mta.py similarity index 95% rename from src/backend/core/tests/api/test_mta.py rename to src/backend/core/tests/api/test_inbound_mta.py index 3653f93f..e2f828de 100644 --- a/src/backend/core/tests/api/test_mta.py +++ b/src/backend/core/tests/api/test_inbound_mta.py @@ -117,8 +117,8 @@ def fixture_jwt_token_without_exp(): class TestMTAInboundEmail: """Test the MTA inbound email endpoint.""" - @patch("core.api.viewsets.mta.deliver_inbound_message") - @patch("core.api.viewsets.mta.parse_email_message") + @patch("core.api.viewsets.inbound.mta.deliver_inbound_message") + @patch("core.api.viewsets.inbound.mta.parse_email_message") @pytest.mark.django_db def test_valid_email_submission( self, @@ -144,7 +144,7 @@ class TestMTAInboundEmail: token = valid_jwt_token(sample_email, {"original_recipients": recipients}) response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=sample_email, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {token}", @@ -164,8 +164,8 @@ class TestMTAInboundEmail: assert first_call_args[1]["subject"] == "Test Email" assert second_call_args[1]["subject"] == "Test Email" - @patch("core.api.viewsets.mta.deliver_inbound_message") - @patch("core.api.viewsets.mta.parse_email_message") + @patch("core.api.viewsets.inbound.mta.deliver_inbound_message") + @patch("core.api.viewsets.inbound.mta.parse_email_message") def test_email_parse_failure( self, mock_parse, @@ -181,7 +181,7 @@ class TestMTAInboundEmail: token = valid_jwt_token(sample_email, {"original_recipients": [email]}) response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=sample_email, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {token}", @@ -192,8 +192,8 @@ class TestMTAInboundEmail: mock_parse.assert_called_once_with(sample_email) mock_deliver.assert_not_called() # Delivery should not be attempted - @patch("core.api.viewsets.mta.deliver_inbound_message") - @patch("core.api.viewsets.mta.parse_email_message") + @patch("core.api.viewsets.inbound.mta.deliver_inbound_message") + @patch("core.api.viewsets.inbound.mta.parse_email_message") def test_delivery_partial_failure( self, mock_parse, @@ -213,7 +213,7 @@ class TestMTAInboundEmail: token = valid_jwt_token(sample_email, {"original_recipients": recipients}) response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=sample_email, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {token}", @@ -234,8 +234,8 @@ class TestMTAInboundEmail: mock_parse.assert_called_once_with(sample_email) assert mock_deliver.call_count == 2 # Called for both recipients - @patch("core.api.viewsets.mta.deliver_inbound_message") - @patch("core.api.viewsets.mta.parse_email_message") + @patch("core.api.viewsets.inbound.mta.deliver_inbound_message") + @patch("core.api.viewsets.inbound.mta.parse_email_message") def test_delivery_total_failure( self, mock_parse, @@ -253,7 +253,7 @@ class TestMTAInboundEmail: token = valid_jwt_token(sample_email, {"original_recipients": recipients}) response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=sample_email, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {token}", @@ -277,7 +277,7 @@ class TestMTAInboundEmail: ): """Test that submitting with an incorrect content type fails (415).""" response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=sample_email, # Data format doesn't matter if content type rejected content_type="application/json", HTTP_AUTHORIZATION=( @@ -290,7 +290,7 @@ class TestMTAInboundEmail: def test_missing_auth_header(self, api_client: APIClient, sample_email): """Test that submitting without an authorization header fails.""" response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=sample_email, content_type="message/rfc822", ) @@ -299,7 +299,7 @@ class TestMTAInboundEmail: def test_invalid_jwt_token(self, api_client: APIClient, sample_email): """Test that submitting with an invalid JWT token fails.""" response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=sample_email, content_type="message/rfc822", HTTP_AUTHORIZATION="Bearer invalid_token", @@ -315,7 +315,7 @@ class TestMTAInboundEmail: sample_email, {"original_recipients": ["recipient@example.com"]} ) response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=sample_email, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {http_token}", @@ -327,7 +327,7 @@ class TestMTAInboundEmail: ): """Test that submitting with a JWT token whose hash doesn't match the body fails.""" response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=sample_email + b"\n one more line", content_type="message/rfc822", HTTP_AUTHORIZATION=( @@ -363,7 +363,7 @@ class TestMTACheckRecipients: token = valid_jwt_token(body, {}) response = api_client.post( - "/api/v1.0/mta/check-recipients/", + "/api/v1.0/inbound/mta/check/", data=body, content_type="application/json", HTTP_AUTHORIZATION=f"Bearer {token}", @@ -385,7 +385,7 @@ class TestMTACheckRecipients: token = valid_jwt_token(body, {}) + "invalid" response = api_client.post( - "/api/v1.0/mta/check-recipients/", + "/api/v1.0/inbound/mta/check/", data=body, content_type="application/json", HTTP_AUTHORIZATION=f"Bearer {token}", @@ -414,7 +414,7 @@ class TestEmailAddressParsing: ).exists() response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=formatted_email, content_type="message/rfc822", HTTP_AUTHORIZATION=( @@ -552,7 +552,7 @@ class TestMTAInboundEmailThreading: ) response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=reply_email_bytes, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {token}", @@ -588,7 +588,7 @@ class TestMTAInboundEmailThreading: ) response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=reply_email_bytes, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {token}", @@ -622,7 +622,7 @@ class TestMTAInboundEmailThreading: ) response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=reply_email_bytes, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {token}", @@ -656,7 +656,7 @@ class TestMTAInboundEmailThreading: ) response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=reply_email_bytes, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {token}", @@ -692,7 +692,7 @@ class TestMTAInboundEmailThreading: ) response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=reply_email_bytes, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {token}", @@ -777,7 +777,7 @@ class TestMTAInboundEmailThreading: ) response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=reply_email_bytes, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {token}", @@ -821,7 +821,7 @@ class TestMTAInboundEmailThreading: ) response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=reply_email_bytes, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {token}", @@ -857,7 +857,7 @@ class TestMTAInboundEmailThreading: ) response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=reply_email_bytes, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {token}", @@ -914,7 +914,7 @@ class TestMTAInboundEmailThreading: # 4. Make the API call response = api_client.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=email_body_bytes, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {token}", diff --git a/src/backend/core/tests/api/test_inbound_widget.py b/src/backend/core/tests/api/test_inbound_widget.py new file mode 100644 index 00000000..f84e534f --- /dev/null +++ b/src/backend/core/tests/api/test_inbound_widget.py @@ -0,0 +1,357 @@ +"""Tests for widget inbound API endpoints.""" + +from unittest.mock import patch + +from django.core.exceptions import ValidationError + +import pytest +from rest_framework import status +from rest_framework.exceptions import AuthenticationFailed +from rest_framework.test import APIClient + +from core import factories +from core.api.viewsets.inbound.widget import WidgetAuthentication + + +@pytest.fixture(name="api_client") +def fixture_api_client(): + """Return an API client.""" + return APIClient() + + +@pytest.fixture(name="channel") +def fixture_channel(): + """Create a test channel with mailbox.""" + mailbox = factories.MailboxFactory() + return factories.ChannelFactory( + type="widget", + mailbox=mailbox, + settings={ + "config": {"enabled": True, "theme": "light"}, + "default_sender_email": "widget@example.com", + "default_sender_name": "Widget Sender", + "intro_text": "Message from widget:", + }, + ) + + +@pytest.fixture(name="channel_with_mailbox_contact") +def fixture_channel_with_mailbox_contact(): + """Create a test channel with mailbox.""" + contact = factories.ContactFactory(email="widget@example.com", name="Widget Sender") + mailbox = factories.MailboxFactory(contact=contact) + return factories.ChannelFactory( + type="widget", + mailbox=mailbox, + settings={ + "config": {"enabled": True, "theme": "light"}, + "default_sender_email": "widget@example.com", + "default_sender_name": "Widget Sender", + "intro_text": "Message from widget:", + }, + ) + + +@pytest.fixture(name="channel_without_mailbox") +def fixture_channel_without_mailbox(): + """Create a test channel without mailbox.""" + return factories.ChannelFactory( + type="widget", + mailbox=None, + maildomain=factories.MailDomainFactory(), + ) + + +@pytest.mark.django_db +def test_channel_model(): + """Test the Channel model.""" + with pytest.raises(ValidationError): + factories.ChannelFactory( + mailbox=factories.MailboxFactory(), + maildomain=factories.MailDomainFactory(), + ) + + with pytest.raises(ValidationError): + factories.ChannelFactory( + mailbox=None, + maildomain=None, + ) + + +@pytest.mark.django_db +class TestWidgetAuthentication: + """Test the WidgetAuthentication class.""" + + def test_authenticate_with_valid_channel_id(self, channel): + """Test authentication with valid channel ID.""" + auth = WidgetAuthentication() + + # Create a mock request with valid channel ID + class MockRequest: + """Mock request.""" + + def __init__(self, channel_id): + """Initialize the mock request.""" + self.headers = {"X-Channel-ID": str(channel_id)} + self.META = {} # pylint: disable=invalid-name + + request = MockRequest(channel.id) + user, auth_data = auth.authenticate(request) + + assert user is None + assert auth_data["channel"] == channel + + def test_authenticate_with_missing_channel_id(self): + """Test authentication fails with missing channel ID.""" + auth = WidgetAuthentication() + + class MockRequest: + """Mock request.""" + + def __init__(self): + """Initialize the mock request.""" + self.headers = {} + self.META = {} # pylint: disable=invalid-name + + request = MockRequest() + + with pytest.raises(AuthenticationFailed, match="Missing channel_id"): + auth.authenticate(request) + + def test_authenticate_with_invalid_channel_id(self): + """Test authentication fails with invalid channel ID.""" + auth = WidgetAuthentication() + + class MockRequest: + """Mock request.""" + + def __init__(self, channel_id): + """Initialize the mock request.""" + self.headers = {"X-Channel-ID": str(channel_id)} + self.META = {} # pylint: disable=invalid-name + + request = MockRequest("invalid-uuid") + + with pytest.raises(ValidationError): + auth.authenticate(request) + + +@pytest.mark.django_db +class TestInboundWidgetConfig: + """Test the config endpoint.""" + + def test_config_success(self, api_client, channel): + """Test successful config retrieval.""" + response = api_client.get( + "/api/v1.0/inbound/widget/config/", + HTTP_X_CHANNEL_ID=str(channel.id), + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "success": True, + "config": {"enabled": True, "theme": "light"}, + } + + def test_config_with_empty_settings(self, api_client): + """Test config with empty settings.""" + channel = factories.ChannelFactory(type="widget", settings={}) + + response = api_client.get( + "/api/v1.0/inbound/widget/config/", + HTTP_X_CHANNEL_ID=str(channel.id), + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"success": True, "config": {}} + + def test_config_without_authentication(self, api_client): + """Test config endpoint without authentication.""" + response = api_client.get("/api/v1.0/inbound/widget/config/") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +class TestInboundWidgetDeliver: + """Test the deliver endpoint.""" + + @patch("core.api.viewsets.inbound.widget.deliver_inbound_message") + def test_deliver_success( + self, mock_deliver, api_client, channel, channel_with_mailbox_contact + ): + """Test successful message delivery.""" + mock_deliver.return_value = True + + data = { + "email": "sender@example.com", + "textBody": "This is a test message from the widget.", + } + + for _channel in [channel, channel_with_mailbox_contact]: + response = api_client.post( + "/api/v1.0/inbound/widget/deliver/", + data=data, + HTTP_X_CHANNEL_ID=str(_channel.id), + HTTP_REFERER="https://example.com/contact", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"success": True} + + # Verify deliver_inbound_message was called + mock_deliver.assert_called_once() + call_args = mock_deliver.call_args[0] + call_kwargs = mock_deliver.call_args[1] + assert call_kwargs["channel"] == _channel + if _channel.mailbox.contact: + assert call_args[0] == str(_channel.mailbox.contact.email) + else: + assert call_args[0] == str(_channel.mailbox) + + mock_deliver.reset_mock() + + def test_deliver_missing_email(self, api_client, channel): + """Test deliver with missing email.""" + data = {"textBody": "This is a test message."} + + response = api_client.post( + "/api/v1.0/inbound/widget/deliver/", + data=data, + HTTP_X_CHANNEL_ID=str(channel.id), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Missing email"} + + def test_deliver_invalid_email(self, api_client, channel): + """Test deliver with invalid email format.""" + data = { + "email": "invalid-email", + "textBody": "This is a test message.", + } + + response = api_client.post( + "/api/v1.0/inbound/widget/deliver/", + data=data, + HTTP_X_CHANNEL_ID=str(channel.id), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Invalid email format"} + + def test_deliver_missing_message(self, api_client, channel): + """Test deliver with missing message.""" + data = {"email": "sender@example.com"} + + response = api_client.post( + "/api/v1.0/inbound/widget/deliver/", + data=data, + HTTP_X_CHANNEL_ID=str(channel.id), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "Missing message"} + + def test_deliver_no_mailbox_configured(self, api_client, channel_without_mailbox): + """Test deliver when no mailbox is configured for the channel.""" + data = { + "email": "sender@example.com", + "textBody": "This is a test message.", + } + + response = api_client.post( + "/api/v1.0/inbound/widget/deliver/", + data=data, + HTTP_X_CHANNEL_ID=str(channel_without_mailbox.id), + ) + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert response.json() == {"detail": "No mailbox configured for this channel"} + + @patch("core.api.viewsets.inbound.widget.deliver_inbound_message") + def test_deliver_with_custom_settings(self, mock_deliver, api_client): + """Test deliver with custom channel settings.""" + mock_deliver.return_value = True + + channel = factories.ChannelFactory( + type="widget", + mailbox=factories.MailboxFactory(), + settings={ + "default_sender_email": "custom@widget.com", + "default_sender_name": "Custom Widget", + "intro_text": "Custom intro text:", + }, + ) + + data = { + "email": "sender@example.com", + "textBody": "Test message with custom settings.", + } + + response = api_client.post( + "/api/v1.0/inbound/widget/deliver/", + data=data, + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_REFERER="https://example.com/contact", + ) + + assert response.status_code == status.HTTP_200_OK + + # Verify the parsed email structure + call_args = mock_deliver.call_args[0] + parsed_email = call_args[1] + + assert parsed_email["from"]["email"] == "custom@widget.com" + assert parsed_email["from"]["name"] == "Custom Widget" + assert "Custom intro text:" in parsed_email["htmlBody"][0]["content"] + + @patch("core.api.viewsets.inbound.widget.deliver_inbound_message") + def test_deliver_message_formatting(self, mock_deliver, api_client, channel): + """Test that message is properly formatted with HTML and signature.""" + mock_deliver.return_value = True + + data = { + "email": "sender@example.com", + "textBody": "Line 1\nLine 2\nLine 3", + } + + response = api_client.post( + "/api/v1.0/inbound/widget/deliver/", + data=data, + HTTP_X_CHANNEL_ID=str(channel.id), + HTTP_REFERER="https://example.com/contact", + ) + + assert response.status_code == status.HTTP_200_OK + + # Verify the parsed email structure and formatting + call_args = mock_deliver.call_args[0] + parsed_email = call_args[1] + + html_content = parsed_email["htmlBody"][0]["content"] + + # Check that newlines are converted to
tags + assert "Line 1
Line 2
Line 3" in html_content + + # Check that signature is included + assert "Sender" in html_content + assert "sender@example.com" in html_content + assert "❌ Unverified" in html_content + assert "IP" in html_content + assert "Page" in html_content + assert "https://example.com/contact" in html_content + + def test_deliver_without_authentication(self, api_client): + """Test deliver endpoint without authentication.""" + data = { + "email": "sender@example.com", + "textBody": "This is a test message.", + } + + response = api_client.post( + "/api/v1.0/inbound/widget/deliver/", + data=data, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/src/backend/core/tests/mda/test_inbound_e2e.py b/src/backend/core/tests/mda/test_inbound_e2e.py index 826e52e5..fd23d3cb 100644 --- a/src/backend/core/tests/mda/test_inbound_e2e.py +++ b/src/backend/core/tests/mda/test_inbound_e2e.py @@ -138,7 +138,7 @@ Content-Disposition: attachment; filename="{attachment_data["filename"]}" ) response = api_client_service_account.post( - "/api/v1.0/mta/inbound-email/", + "/api/v1.0/inbound/mta/deliver/", data=multipart_email_with_attachment, content_type="message/rfc822", HTTP_AUTHORIZATION=f"Bearer {token}", diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 4fb0c5fd..4c75e8ff 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -9,8 +9,10 @@ from core.api.viewsets.blob import BlobViewSet from core.api.viewsets.config import ConfigView from core.api.viewsets.contacts import ContactViewSet from core.api.viewsets.draft import DraftMessageView -from core.api.viewsets.flag import ChangeFlagViewSet +from core.api.viewsets.flag import ChangeFlagView from core.api.viewsets.import_message import ImportViewSet +from core.api.viewsets.inbound.mta import InboundMTAViewSet +from core.api.viewsets.inbound.widget import InboundWidgetViewSet from core.api.viewsets.label import LabelViewSet from core.api.viewsets.mailbox import MailboxViewSet from core.api.viewsets.mailbox_access import MailboxAccessViewSet @@ -23,7 +25,6 @@ from core.api.viewsets.maildomain import ( from core.api.viewsets.maildomain_access import MaildomainAccessViewSet from core.api.viewsets.message import MessageViewSet from core.api.viewsets.metrics import MailDomainUsersMetricsApiView -from core.api.viewsets.mta import MTAViewSet from core.api.viewsets.placeholder import PlaceholderView from core.api.viewsets.send import SendMessageView from core.api.viewsets.task import TaskDetailView @@ -34,7 +35,6 @@ from core.authentication.urls import urlpatterns as oidc_urls # - Main endpoints router = DefaultRouter() -router.register("mta", MTAViewSet, basename="mta") router.register("users", UserViewSet, basename="users") router.register("messages", MessageViewSet, basename="messages") router.register("blob", BlobViewSet, basename="blob") @@ -69,6 +69,13 @@ maildomain_nested_router.register( r"accesses", MaildomainAccessViewSet, basename="admin-maildomains-access" ) +# Router for /inbound/ +inbound_nested_router = DefaultRouter() +inbound_nested_router.register(r"mta", InboundMTAViewSet, basename="inbound-mta") +inbound_nested_router.register( + r"widget", InboundWidgetViewSet, basename="inbound-widget" +) + urlpatterns = [ path( f"api/{settings.API_VERSION}/", @@ -91,6 +98,10 @@ urlpatterns = [ "maildomains//", include(maildomain_nested_router.urls), ), + path( + "inbound/", + include(inbound_nested_router.urls), + ), *oidc_urls, ] ), @@ -98,7 +109,7 @@ urlpatterns = [ path(f"api/{settings.API_VERSION}/config/", ConfigView.as_view()), path( f"api/{settings.API_VERSION}/flag/", - ChangeFlagViewSet.as_view(), + ChangeFlagView.as_view(), name="change-flag", ), path( @@ -141,6 +152,18 @@ urlpatterns = [ MailDomainUsersMetricsApiView.as_view(), name="maildomain-users-metrics", ), + # Alias for MTA check endpoint + path( + f"api/{settings.API_VERSION}/mta/check-recipients/", + InboundMTAViewSet.as_view({"post": "check"}), + name="mta-check-recipients", + ), + # Alias for MTA deliver endpoint + path( + f"api/{settings.API_VERSION}/mta/inbound-email/", + InboundMTAViewSet.as_view({"post": "deliver"}), + name="mta-inbound-email", + ), ] if settings.ENABLE_PROMETHEUS: diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index cbc72321..811d71ad 100755 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -91,6 +91,10 @@ class Base(Configuration): SECRET_KEY = values.Value(None) SERVER_TO_SERVER_API_TOKENS = values.ListValue([]) + USE_X_FORWARDED_FOR = values.BooleanValue( + default=False, environ_name="USE_X_FORWARDED_FOR", environ_prefix=None + ) + # Application definition ROOT_URLCONF = "messages.urls" WSGI_APPLICATION = "messages.wsgi.application" @@ -375,7 +379,7 @@ class Base(Configuration): "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.locale.LocaleMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "corsheaders.middleware.CorsMiddleware", + "core.middlewares.CustomCorsMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", @@ -480,15 +484,6 @@ class Base(Configuration): None, environ_name="FRONTEND_THEME", environ_prefix=None ) - # Posthog - POSTHOG_KEY = values.Value(None, environ_name="POSTHOG_KEY", environ_prefix=None) - POSTHOG_HOST = values.Value( - "https://eu.i.posthog.com", environ_name="POSTHOG_HOST", environ_prefix=None - ) - POSTHOG_SURVEY_ID = values.Value( - None, environ_name="POSTHOG_SURVEY_ID", environ_prefix=None - ) - # Celery CELERY_BROKER_URL = values.Value( "redis://redis:6379", environ_name="CELERY_BROKER_URL", environ_prefix=None @@ -708,6 +703,9 @@ class Base(Configuration): "django_prometheus.middleware.PrometheusAfterMiddleware", ] + if self.USE_X_FORWARDED_FOR: + self.MIDDLEWARE.insert(0, "core.middlewares.XForwardedForMiddleware") + if os.environ.get("MTA_OUT_SMTP_HOST") and not self.MTA_OUT_RELAY_HOST: logger.warning( "MTA_OUT_SMTP_HOST is deprecated, use MTA_OUT_RELAY_HOST instead" diff --git a/src/frontend/Dockerfile b/src/frontend/Dockerfile index 9d384912..86d039c3 100644 --- a/src/frontend/Dockerfile +++ b/src/frontend/Dockerfile @@ -2,53 +2,9 @@ FROM node:22-slim AS frontend-deps WORKDIR /home/frontend/ -COPY ./src/frontend/package.json ./package.json -COPY ./src/frontend/package-lock.json ./package-lock.json +RUN npm install -g npm@11.3.0 && npm cache clean -f -RUN npm ci +ARG DOCKER_USER +USER ${DOCKER_USER} -COPY .dockerignore ./.dockerignore -# COPY ./src/frontend/.prettierrc.js ./.prettierrc.js -#COPY ./src/frontend/packages/eslint-config-messages ./packages/eslint-config-messages -COPY ./src/frontend ./apps/messages - -### ---- Front-end builder image ---- -FROM frontend-deps AS st-messages-dev - -WORKDIR /home/frontend/apps/messages - -ARG API_ORIGIN -ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN} - -EXPOSE 3000 - -CMD [ "npm", "run", "dev"] - -# # Tilt will rebuild messages target so, we dissociate messages and messages-builder -# # to avoid rebuilding the app at every changes. -# FROM messages AS messages-builder - -# WORKDIR /home/frontend/apps/messages - -# ARG S3_DOMAIN_REPLACE -# ENV NEXT_PUBLIC_S3_DOMAIN_REPLACE=${S3_DOMAIN_REPLACE} - -# RUN npm run build - -# # ---- Front-end image ---- -# FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production - -# # Un-privileged user running the application -# ARG DOCKER_USER -# USER ${DOCKER_USER} - -# COPY --from=messages-builder \ -# /home/frontend/apps/messages/out \ -# /usr/share/nginx/html - -# COPY ./src/frontend/conf/default.conf /etc/nginx/conf.d -# COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint - -# ENTRYPOINT [ "/usr/local/bin/entrypoint" ] - -# CMD ["nginx", "-g", "daemon off;"] +ENV npm_config_cache=/tmp/npm-cache diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index aa6d67d7..01481bb8 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -26,7 +26,6 @@ "downshift": "9.0.10", "i18next": "25.3.0", "next": "15.3.4", - "posthog-js": "1.257.0", "pretty-bytes": "7.0.0", "react": "19.1.0", "react-dom": "19.1.0", @@ -7519,17 +7518,6 @@ "node": ">= 0.6" } }, - "node_modules/core-js": { - "version": "3.44.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.44.0.tgz", - "integrity": "sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/cors": { "version": "2.8.5", "license": "MIT", @@ -12546,46 +12534,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/posthog-js": { - "version": "1.257.0", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.257.0.tgz", - "integrity": "sha512-Ujg9RGtWVCu+4tmlRpALSy2ZOZI6JtieSYXIDDdgMWm167KYKvTtbMPHdoBaPWcNu0Km+1hAIBnQFygyn30KhA==", - "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "core-js": "^3.38.1", - "fflate": "^0.4.8", - "preact": "^10.19.3", - "web-vitals": "^4.2.4" - }, - "peerDependencies": { - "@rrweb/types": "2.0.0-alpha.17", - "rrweb-snapshot": "2.0.0-alpha.17" - }, - "peerDependenciesMeta": { - "@rrweb/types": { - "optional": true - }, - "rrweb-snapshot": { - "optional": true - } - } - }, - "node_modules/posthog-js/node_modules/fflate": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", - "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", - "license": "MIT" - }, - "node_modules/preact": { - "version": "10.26.9", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.9.tgz", - "integrity": "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -15776,12 +15724,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/web-vitals": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", - "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "dev": true, diff --git a/src/frontend/package.json b/src/frontend/package.json index 8ad706d5..51348242 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -38,7 +38,6 @@ "downshift": "9.0.10", "i18next": "25.3.0", "next": "15.3.4", - "posthog-js": "1.257.0", "pretty-bytes": "7.0.0", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/src/frontend/src/features/api/gen/index.ts b/src/frontend/src/features/api/gen/index.ts index 2ea5cfba..0d9929ff 100644 --- a/src/frontend/src/features/api/gen/index.ts +++ b/src/frontend/src/features/api/gen/index.ts @@ -9,7 +9,6 @@ export * from "./mailboxes/mailboxes"; export * from "./mailbox-accesses/mailbox-accesses"; export * from "./maildomains/maildomains"; export * from "./maildomain-accesses/maildomain-accesses"; -export * from "./mta/mta"; export * from "./placeholders/placeholders"; export * from "./tasks/tasks"; export * from "./threads/threads"; 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 43d242c5..075bf5d0 100644 --- a/src/frontend/src/features/api/gen/models/config_retrieve200.ts +++ b/src/frontend/src/features/api/gen/models/config_retrieve200.ts @@ -11,12 +11,6 @@ import type { ConfigRetrieve200SCHEMACUSTOMATTRIBUTESMAILDOMAIN } from "./config export type ConfigRetrieve200 = { readonly ENVIRONMENT: string; - /** @nullable */ - readonly POSTHOG_KEY: string | null; - /** @nullable */ - readonly POSTHOG_HOST: string | null; - /** @nullable */ - readonly POSTHOG_SURVEY_ID: string | null; readonly LANGUAGES: readonly string[]; readonly LANGUAGE_CODE: string; readonly AI_ENABLED: boolean; diff --git a/src/frontend/src/features/api/gen/mta/mta.ts b/src/frontend/src/features/api/gen/mta/mta.ts deleted file mode 100644 index 901c390c..00000000 --- a/src/frontend/src/features/api/gen/mta/mta.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Generated by orval v7.10.0 🍺 - * Do not edit manually. - * messages API - * This is the messages API schema. - * OpenAPI spec version: 1.0.0 (v1.0) - */ -import { useMutation } from "@tanstack/react-query"; -import type { - MutationFunction, - QueryClient, - UseMutationOptions, - UseMutationResult, -} from "@tanstack/react-query"; - -import { fetchAPI } from "../../fetch-api"; - -type SecondParameter unknown> = Parameters[1]; - -/** - * Check if recipient email addresses exist for the MTA. - */ -export type mtaCheckRecipientsCreateResponse200 = { - data: void; - status: 200; -}; - -export type mtaCheckRecipientsCreateResponseComposite = - mtaCheckRecipientsCreateResponse200; - -export type mtaCheckRecipientsCreateResponse = - mtaCheckRecipientsCreateResponseComposite & { - headers: Headers; - }; - -export const getMtaCheckRecipientsCreateUrl = () => { - return `/api/v1.0/mta/check-recipients/`; -}; - -export const mtaCheckRecipientsCreate = async ( - options?: RequestInit, -): Promise => { - return fetchAPI( - getMtaCheckRecipientsCreateUrl(), - { - ...options, - method: "POST", - }, - ); -}; - -export const getMtaCheckRecipientsCreateMutationOptions = < - TError = unknown, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - void, - TContext - >; - request?: SecondParameter; -}): UseMutationOptions< - Awaited>, - TError, - void, - TContext -> => { - const mutationKey = ["mtaCheckRecipientsCreate"]; - const { mutation: mutationOptions, request: requestOptions } = options - ? options.mutation && - "mutationKey" in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; - - const mutationFn: MutationFunction< - Awaited>, - void - > = () => { - return mtaCheckRecipientsCreate(requestOptions); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type MtaCheckRecipientsCreateMutationResult = NonNullable< - Awaited> ->; - -export type MtaCheckRecipientsCreateMutationError = unknown; - -export const useMtaCheckRecipientsCreate = < - TError = unknown, - TContext = unknown, ->( - options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - void, - TContext - >; - request?: SecondParameter; - }, - queryClient?: QueryClient, -): UseMutationResult< - Awaited>, - TError, - void, - TContext -> => { - const mutationOptions = getMtaCheckRecipientsCreateMutationOptions(options); - - return useMutation(mutationOptions, queryClient); -}; -/** - * Handle incoming raw email (message/rfc822) from MTA. - */ -export type mtaInboundEmailCreateResponse200 = { - data: void; - status: 200; -}; - -export type mtaInboundEmailCreateResponseComposite = - mtaInboundEmailCreateResponse200; - -export type mtaInboundEmailCreateResponse = - mtaInboundEmailCreateResponseComposite & { - headers: Headers; - }; - -export const getMtaInboundEmailCreateUrl = () => { - return `/api/v1.0/mta/inbound-email/`; -}; - -export const mtaInboundEmailCreate = async ( - options?: RequestInit, -): Promise => { - return fetchAPI( - getMtaInboundEmailCreateUrl(), - { - ...options, - method: "POST", - }, - ); -}; - -export const getMtaInboundEmailCreateMutationOptions = < - TError = unknown, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - void, - TContext - >; - request?: SecondParameter; -}): UseMutationOptions< - Awaited>, - TError, - void, - TContext -> => { - const mutationKey = ["mtaInboundEmailCreate"]; - const { mutation: mutationOptions, request: requestOptions } = options - ? options.mutation && - "mutationKey" in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey }, request: undefined }; - - const mutationFn: MutationFunction< - Awaited>, - void - > = () => { - return mtaInboundEmailCreate(requestOptions); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type MtaInboundEmailCreateMutationResult = NonNullable< - Awaited> ->; - -export type MtaInboundEmailCreateMutationError = unknown; - -export const useMtaInboundEmailCreate = ( - options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - void, - TContext - >; - request?: SecondParameter; - }, - queryClient?: QueryClient, -): UseMutationResult< - Awaited>, - TError, - void, - TContext -> => { - const mutationOptions = getMtaInboundEmailCreateMutationOptions(options); - - return useMutation(mutationOptions, queryClient); -}; diff --git a/src/frontend/src/features/auth/index.tsx b/src/frontend/src/features/auth/index.tsx index b9d8f313..29e43bf0 100644 --- a/src/frontend/src/features/auth/index.tsx +++ b/src/frontend/src/features/auth/index.tsx @@ -3,11 +3,9 @@ import React, { PropsWithChildren, useEffect } from "react"; import { getRequestUrl } from "@/features/api/utils"; import { useUsersMeRetrieve } from "@/features/api/gen/users/users"; import { Spinner } from "@gouvfr-lasuite/ui-kit"; -import { posthog } from "posthog-js"; import { UserWithAbilities } from "../api/gen/models/user_with_abilities"; export const logout = () => { - posthog.reset() window.location.replace(getRequestUrl("/api/v1.0/logout/")); }; @@ -37,16 +35,6 @@ export const Auth = ({ } }, [query.isError, redirect]); - useEffect(() => { - if (query.data?.data) { - const user = query.data.data; - posthog.identify(user.id, { - email: user.email || undefined, - name: user.full_name || undefined, - }); - } - }, [query.data?.data]); - if (!query.isFetched) { return (
{leftIcon} - { - isDesktop && ( - - ) - }
{isDesktop && ( diff --git a/src/frontend/src/features/layouts/components/main/left-panel/index.tsx b/src/frontend/src/features/layouts/components/main/left-panel/index.tsx index 778ffa5d..49f201de 100644 --- a/src/frontend/src/features/layouts/components/main/left-panel/index.tsx +++ b/src/frontend/src/features/layouts/components/main/left-panel/index.tsx @@ -1,16 +1,12 @@ import { useResponsive } from "@gouvfr-lasuite/ui-kit"; -import { usePostHog } from "posthog-js/react"; import { useAuth } from "@/features/auth"; import { HeaderRight } from "../header/authenticated"; -import { PostHogSurveyButton } from "@/features/ui/components/feedback-button"; -import { useConfig } from "@/features/providers/config"; +import { SurveyButton } from "@/features/ui/components/feedback-button"; import { MailboxPanel } from "../../mailbox-panel"; import { LanguagePicker } from "../language-picker"; export const LeftPanel = ({ hasNoMailbox = true }: { hasNoMailbox?: boolean }) => { const { user } = useAuth(); - const posthog = usePostHog(); - const config = useConfig(); const { isTablet } = useResponsive(); if (!isTablet && hasNoMailbox) return null; @@ -25,9 +21,9 @@ export const LeftPanel = ({ hasNoMailbox = true }: { hasNoMailbox?: boolean }) = {user ? : }
} - {posthog.__loaded && config.POSTHOG_SURVEY_ID && ( + {process.env.NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL && (
- +
)}
diff --git a/src/frontend/src/features/providers/config.tsx b/src/frontend/src/features/providers/config.tsx index fd4b81ac..817cfcfa 100644 --- a/src/frontend/src/features/providers/config.tsx +++ b/src/frontend/src/features/providers/config.tsx @@ -4,9 +4,6 @@ import { PropsWithChildren, createContext, useContext, useMemo } from "react"; const DEFAULT_CONFIG: ConfigRetrieve200 = { ENVIRONMENT: "", - POSTHOG_KEY: null, - POSTHOG_HOST: null, - POSTHOG_SURVEY_ID: null, LANGUAGES: [], LANGUAGE_CODE: "", AI_ENABLED: false, diff --git a/src/frontend/src/features/providers/posthog.tsx b/src/frontend/src/features/providers/posthog.tsx deleted file mode 100644 index 6536bff7..00000000 --- a/src/frontend/src/features/providers/posthog.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { PostHogProvider as PostHogProviderBase } from "posthog-js/react"; -import { PropsWithChildren } from "react"; -import { useConfig } from './config'; - -/** - * A global provider in charge of initializing PostHog if the config has - * the POSTHOG_KEY and POSTHOG_HOST set. - */ -export const PostHogProvider = ({ children, }: PropsWithChildren) => { - const config = useConfig(); - - if (!config?.POSTHOG_KEY || !config?.POSTHOG_HOST) { - return children; - } - - return ( - - {children} - - ); -}; diff --git a/src/frontend/src/features/ui/components/feedback-button/index.tsx b/src/frontend/src/features/ui/components/feedback-button/index.tsx index fd6c3326..a9d043a9 100644 --- a/src/frontend/src/features/ui/components/feedback-button/index.tsx +++ b/src/frontend/src/features/ui/components/feedback-button/index.tsx @@ -1,33 +1,81 @@ -import { useConfig } from "@/features/providers/config" import { Icon, IconType } from "@gouvfr-lasuite/ui-kit" import { Button, ButtonProps } from "@openfun/cunningham-react" -import { usePostHog } from "posthog-js/react" import { useTranslation } from "react-i18next" +import { useAuth } from "@/features/auth"; /** - * A button that opens the PostHog survey modal. - * - * This button is only visible if PostHog is loaded. To work, a survey must be - * created in PostHog with type Feedback button and as CSS Selector you must - * use `#posthog-feedback-survey`. - * + * A button that opens the feedback widget */ -export const PostHogSurveyButton = (props: ButtonProps) => { +export const SurveyButton = (props: ButtonProps) => { const { t } = useTranslation() - const posthog = usePostHog() - const config = useConfig() + const { user } = useAuth(); + + const apiUrl = process.env.NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL; + const widgetPath = process.env.NEXT_PUBLIC_FEEDBACK_WIDGET_PATH; + const channel = process.env.NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL; + + if (!channel || !apiUrl || !widgetPath) return null; + + const title: string = t("feedback_widget.title"); + const placeholder: string = t("feedback_widget.placeholder"); + const emailPlaceholder: string = t("feedback_widget.email_placeholder"); + const submitText: string = t("feedback_widget.submit_text"); + const successText: string = t("feedback_widget.success_text"); + const successText2: string = t("feedback_widget.success_text2"); + + + const showWidget = () => { + // Initialize the widget array if it doesn't exist + if (typeof window !== "undefined" && widgetPath) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)._stmsg_widget = (window as any)._stmsg_widget || []; + + // Construct script URLs from the base path + const feedbackScript = `${widgetPath}feedback.js`; + + // Push the widget configuration + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)._stmsg_widget.push([ + "feedback", + "init", + { + title, + api: apiUrl, + channel, + placeholder, + emailPlaceholder, + submitText, + successText, + successText2, + // Add email parameter if user is logged in + ...(user?.email && { email: user.email }), + }, + ]); + + // Load the loader script if not already loaded + if (!document.querySelector(`script[src="${feedbackScript}"]`)) { + const script = document.createElement("script"); + script.async = true; + script.src = feedbackScript; + const firstScript = document.getElementsByTagName("script")[0]; + if (firstScript && firstScript.parentNode) { + firstScript.parentNode.insertBefore(script, firstScript); + } + } + } + } - if (!config.POSTHOG_SURVEY_ID || !posthog.__loaded) return null; return ( ) } diff --git a/src/frontend/src/features/ui/components/feedback-widget/index.tsx b/src/frontend/src/features/ui/components/feedback-widget/index.tsx new file mode 100644 index 00000000..815ffbf1 --- /dev/null +++ b/src/frontend/src/features/ui/components/feedback-widget/index.tsx @@ -0,0 +1,79 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@/features/auth"; + +interface FeedbackWidgetProps { + widget?: string; +} + +export function FeedbackWidget({ + widget = "feedback", +}: FeedbackWidgetProps) { + const { t } = useTranslation(); + const { user } = useAuth(); + + const apiUrl = process.env.NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL; + const widgetPath = process.env.NEXT_PUBLIC_FEEDBACK_WIDGET_PATH; + const channel = process.env.NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL; + + const title: string = t("feedback_widget.title"); + const placeholder: string = t("feedback_widget.placeholder"); + const emailPlaceholder: string = t("feedback_widget.email_placeholder"); + const submitText: string = t("feedback_widget.submit_text"); + const successText: string = t("feedback_widget.success_text"); + const successText2: string = t("feedback_widget.success_text2"); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (!channel || !apiUrl || !widgetPath) return; + + // Initialize the widget array if it doesn't exist + if (typeof window !== "undefined" && widgetPath) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)._stmsg_widget = (window as any)._stmsg_widget || []; + + // Construct script URLs from the base path + const loaderScript = `${widgetPath}loader.js`; + const feedbackScript = `${widgetPath}feedback.js`; + + // Push the widget configuration + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)._stmsg_widget.push([ + "loader", + "init", + { + params: { + title, + api: apiUrl, + channel, + placeholder, + emailPlaceholder, + submitText, + successText, + successText2, + // Add email parameter if user is logged in + ...(user?.email && { email: user.email }), + }, + script: feedbackScript, + widget, + label: title, + }, + ]); + + // Load the loader script if not already loaded + if (!document.querySelector(`script[src="${loaderScript}"]`)) { + const script = document.createElement("script"); + script.async = true; + script.src = loaderScript; + const firstScript = document.getElementsByTagName("script")[0]; + if (firstScript && firstScript.parentNode) { + firstScript.parentNode.insertBefore(script, firstScript); + } + } + } + }, [title, channel, apiUrl, widgetPath, widget, emailPlaceholder, submitText, successText, successText2, user?.email]); + + // This component doesn't render anything visible + // The widget is injected via the script + return null; +} diff --git a/src/frontend/src/pages/_app.tsx b/src/frontend/src/pages/_app.tsx index 95577387..e903f38b 100644 --- a/src/frontend/src/pages/_app.tsx +++ b/src/frontend/src/pages/_app.tsx @@ -9,7 +9,7 @@ import { QueryClient, QueryClientProvider, } from "@tanstack/react-query"; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +// import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import "../styles/main.scss"; import "../features/i18n/initI18n"; @@ -22,7 +22,6 @@ import Head from "next/head"; import { useTranslation } from "react-i18next"; import { Auth } from "@/features/auth"; import { ConfigProvider } from "@/features/providers/config"; -import { PostHogProvider } from "@/features/providers/posthog"; export type NextPageWithLayout

= NextPage & { getLayout?: (page: ReactElement) => ReactNode; @@ -86,15 +85,13 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { /> - + {/* */} - - - - {getLayout()} - - - + + + {getLayout()} + + diff --git a/src/frontend/src/pages/_document.tsx b/src/frontend/src/pages/_document.tsx index 743d7c35..9cff10fd 100644 --- a/src/frontend/src/pages/_document.tsx +++ b/src/frontend/src/pages/_document.tsx @@ -1,6 +1,8 @@ import { Html, Head, Main, NextScript } from "next/document"; import { useTranslation } from "react-i18next"; + + export default function Document() { const { t } = useTranslation(); diff --git a/src/frontend/src/pages/index.tsx b/src/frontend/src/pages/index.tsx index 365303f4..49caf5e8 100644 --- a/src/frontend/src/pages/index.tsx +++ b/src/frontend/src/pages/index.tsx @@ -5,6 +5,7 @@ import { MainLayout } from "@/features/layouts/components/main"; import { LanguagePicker } from "@/features/layouts/components/main/language-picker"; import { AppLayout } from "@/features/layouts/components/main/layout"; import { LeftPanel } from "@/features/layouts/components/main/left-panel"; +import { FeedbackWidget } from "@/features/ui/components/feedback-widget"; export default function HomePage() { @@ -35,6 +36,7 @@ export default function HomePage() {