(channels) add feedback widget and multiple inbound channels support (#301)

This PR adds a new build system for embeddable widgets and a first implementation of a "Feedback" popup widget.

It also refactors inbound message routes into channels, of which there are 2 for now: MTA (by default) and Widget. More to come!
This commit is contained in:
Sylvain Zimmer
2025-09-24 14:30:40 +02:00
committed by GitHub
parent 28137bfdc4
commit f4eac6dd8f
69 changed files with 3105 additions and 659 deletions
+4
View File
@@ -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
+45 -17
View File
@@ -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 \
+22
View File
@@ -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).
+21 -3
View File
@@ -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:
-8
View File
@@ -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 |
+4 -1
View File
@@ -1,3 +1,6 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8901
NEXT_PUBLIC_S3_DOMAIN_REPLACE=http://localhost:9000
NEXT_TELEMETRY_DISABLED=1
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=
+1
View File
@@ -0,0 +1 @@
# Nothing yet!
+27 -1
View File
@@ -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")
-46
View File
@@ -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",
+36
View File
@@ -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
-21
View File
@@ -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",
+1 -1
View File
@@ -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]
@@ -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)
@@ -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:
@@ -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"<a href='mailto:{escaped_email}'>{escaped_email}</a> (❌ Unverified)",
),
("IP", request.META.get("REMOTE_ADDR")), # TODO geoip
(
"Page",
(
f"<a href='{html_escape(request.META.get('HTTP_REFERER'))}'"
+ " target='_blank' rel='noopener noreferrer'>"
+ f"{html_escape(request.META.get('HTTP_REFERER'))}</a>"
),
),
]
message_text = (
intro_text
+ "<br/><br/>"
+ html_escape(message_text).replace("\n", "<br/>")
+ "<br/><br/>--<br/>"
+ "<br/>".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,
}
)
@@ -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'"
+12
View File
@@ -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)
+2
View File
@@ -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
+105 -1
View File
@@ -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)
@@ -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'),
),
]
+62
View File
@@ -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",
@@ -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,
@@ -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}",
@@ -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 <br/> tags
assert "Line 1<br/>Line 2<br/>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
@@ -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}",
+27 -4
View File
@@ -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/<uuid:maildomain_pk>/",
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:
+8 -10
View File
@@ -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"
+4 -48
View File
@@ -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
-58
View File
@@ -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,
-1
View File
@@ -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",
@@ -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";
@@ -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;
@@ -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<T extends (...args: never) => unknown> = Parameters<T>[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<mtaCheckRecipientsCreateResponse> => {
return fetchAPI<mtaCheckRecipientsCreateResponse>(
getMtaCheckRecipientsCreateUrl(),
{
...options,
method: "POST",
},
);
};
export const getMtaCheckRecipientsCreateMutationOptions = <
TError = unknown,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof mtaCheckRecipientsCreate>>,
TError,
void,
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
}): UseMutationOptions<
Awaited<ReturnType<typeof mtaCheckRecipientsCreate>>,
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<ReturnType<typeof mtaCheckRecipientsCreate>>,
void
> = () => {
return mtaCheckRecipientsCreate(requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type MtaCheckRecipientsCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof mtaCheckRecipientsCreate>>
>;
export type MtaCheckRecipientsCreateMutationError = unknown;
export const useMtaCheckRecipientsCreate = <
TError = unknown,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof mtaCheckRecipientsCreate>>,
TError,
void,
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof mtaCheckRecipientsCreate>>,
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<mtaInboundEmailCreateResponse> => {
return fetchAPI<mtaInboundEmailCreateResponse>(
getMtaInboundEmailCreateUrl(),
{
...options,
method: "POST",
},
);
};
export const getMtaInboundEmailCreateMutationOptions = <
TError = unknown,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof mtaInboundEmailCreate>>,
TError,
void,
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
}): UseMutationOptions<
Awaited<ReturnType<typeof mtaInboundEmailCreate>>,
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<ReturnType<typeof mtaInboundEmailCreate>>,
void
> = () => {
return mtaInboundEmailCreate(requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type MtaInboundEmailCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof mtaInboundEmailCreate>>
>;
export type MtaInboundEmailCreateMutationError = unknown;
export const useMtaInboundEmailCreate = <TError = unknown, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof mtaInboundEmailCreate>>,
TError,
void,
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof mtaInboundEmailCreate>>,
TError,
void,
TContext
> => {
const mutationOptions = getMtaInboundEmailCreateMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
-12
View File
@@ -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 (
<div
@@ -515,8 +515,14 @@
"no_records": "No DNS records found",
"copy": "Copy"
},
"posthog": {
"cta": "Give feedback"
"feedback_widget": {
"shortTitle": "Feedback?",
"title": "Do you have any feedback?",
"placeholder": "Share your feedback here...",
"email_placeholder": "Your email...",
"submit_text": "Send Feedback",
"success_text": "Thank you for your feedback!",
"success_text2": "In case of questions, we'll get back to you soon."
}
}
},
@@ -1029,8 +1035,14 @@
"no_records": "Aucun enregistrement DNS trouvé",
"copy": "Copier"
},
"posthog": {
"cta": "Faire un retour"
"feedback_widget": {
"shortTitle": "Faire un retour",
"title": "Partager un retour ou une question",
"placeholder": "Saisir votre message...",
"email_placeholder": "Renseigner votre email...",
"submit_text": "Envoyer le message",
"success_text": "Merci pour votre message.",
"success_text2": "En cas de questions, nous vous répondrons dans les meilleurs délais sur l'email renseigné."
}
}
}
@@ -1,7 +1,6 @@
import { HeaderProps, useResponsive } from "@gouvfr-lasuite/ui-kit";
import { Button } from "@openfun/cunningham-react";
import { useTranslation } from "react-i18next";
import { PostHogSurveyButton } from "@/features/ui/components/feedback-button";
import { LanguagePicker } from "../language-picker";
@@ -30,11 +29,6 @@ export const AnonymousHeader = ({
</div>
<div className="c__header__left">
{leftIcon}
{
isDesktop && (
<PostHogSurveyButton />
)
}
</div>
<div className="c__header__right">
{isDesktop && (
@@ -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 ? <HeaderRight /> : <LanguagePicker />}
</div>
}
{posthog.__loaded && config.POSTHOG_SURVEY_ID && (
{process.env.NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL && (
<div className="left-panel__footer">
<PostHogSurveyButton fullWidth />
<SurveyButton fullWidth />
</div>
)}
</div>
@@ -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,
@@ -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 (
<PostHogProviderBase
apiKey={config?.POSTHOG_KEY}
options={{
api_host: config?.POSTHOG_HOST,
defaults: "2025-05-24",
debug: config.ENVIRONMENT === "development",
}}
>
{children}
</PostHogProviderBase>
);
};
@@ -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 (
<Button
{...props}
icon={<Icon name="info" type={IconType.FILLED} />}
color="tertiary"
className="feedback-button posthog-feedback-survey"
title={t("posthog.cta")}
className="feedback-button"
title={t("feedback_widget.title")}
onClick={showWidget}
>
{t("posthog.cta")}
{t("feedback_widget.shortTitle")}
</Button>
)
}
@@ -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;
}
+7 -10
View File
@@ -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<P = object, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode;
@@ -86,15 +85,13 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
/>
</Head>
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />
{/* <ReactQueryDevtools initialIsOpen={false} /> */}
<ConfigProvider>
<PostHogProvider>
<CunninghamProvider currentLocale={i18n.language}>
<Auth>
{getLayout(<Component {...pageProps} />)}
</Auth>
</CunninghamProvider>
</PostHogProvider>
<CunninghamProvider currentLocale={i18n.language}>
<Auth>
{getLayout(<Component {...pageProps} />)}
</Auth>
</CunninghamProvider>
</ConfigProvider>
</QueryClientProvider>
</>
+2
View File
@@ -1,6 +1,8 @@
import { Html, Head, Main, NextScript } from "next/document";
import { useTranslation } from "react-i18next";
export default function Document() {
const { t } = useTranslation();
+2
View File
@@ -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() {
</HomeGutter>
<Footer />
</div>
<FeedbackWidget />
</AppLayout>
);
}
+3 -3
View File
@@ -9,9 +9,9 @@ This MTA container is based on standard technologies such as Postfix with a cust
It is battle-tested with a complete Python test suite.
After receiving an email through SMTP, it processes each message synchronously during the SMTP session using a custom Postfix milter that:
- Validates each recipient with a REST API call to `{env.MDA_API_BASE_URL}/check-recipients` during the RCPT TO command
- Delivers the complete message via REST API call to `{env.MDA_API_BASE_URL}/inbound-email` during the DATA command
- Either accepts (discards from queue) or rejects the SMTP session based on delivery results
- Validates each recipient with a REST API call to `{env.MDA_API_BASE_URL}/inbound/mta/check/` during the RCPT TO command
- Delivers the complete message via REST API call to `{env.MDA_API_BASE_URL}/inbound/mta/deliver/` during the DATA command
- Either accepts (discards from queue) or rejects the SMTP session based on delivery results
This architecture ensures true synchronous delivery - delivery failures cause immediate SMTP session rejection, and successful deliveries prevent the message from entering the Postfix queue.
+2 -2
View File
@@ -63,7 +63,7 @@ class DeliveryMilter(Milter.Base):
try:
# Check if recipient exists via MDA API
status_code, response = mda_api_call(
"check-recipients/",
"inbound/mta/check/",
"application/json",
json.dumps({"addresses": [clean_to]}).encode("utf-8"),
{},
@@ -122,7 +122,7 @@ class DeliveryMilter(Milter.Base):
# Perform synchronous delivery via MDA API
status_code, response = mda_api_call(
"inbound-email/",
"inbound/mta/deliver/",
"message/rfc822",
message_content,
{
+2 -2
View File
@@ -67,7 +67,7 @@ class MockAPIServer:
return await call_next(request)
@self.app.post("/api/mail/inbound-email/")
@self.app.post("/api/mail/inbound/mta/deliver/")
async def receive_mail(request: Request):
logger.info("Email received by API!")
@@ -93,7 +93,7 @@ class MockAPIServer:
self.received_emails.append(email_data)
return {"status": "ok"}
@self.app.post("/api/mail/check-recipients/")
@self.app.post("/api/mail/inbound/mta/check/")
async def check_recipient(request: Request):
logger.info("Recipient check received")
data = await request.json()
+2 -2
View File
@@ -19,7 +19,7 @@ server {
location ^~ /api/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_redirect off;
proxy_pass http://backend_server;
@@ -29,7 +29,7 @@ server {
location ^~ /<%= ENV["DJANGO_ADMIN_URL"] || "admin/" %> {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_redirect off;
proxy_pass http://backend_server;
@@ -1,6 +1,6 @@
FROM node:22-slim AS frontend-deps
FROM node:22-slim AS widgets-deps
WORKDIR /home/frontend/
WORKDIR /home/widgets/
RUN npm install -g npm@11.3.0 && npm cache clean -f
+30
View File
@@ -0,0 +1,30 @@
import { build } from 'vite'
import { readdirSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const widgetsDir = join(__dirname, 'src', 'widgets')
function discoverWidgets() {
return readdirSync(widgetsDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
}
// Run an independent build for each widget
for (const widget of discoverWidgets()) {
await build({
build: {
emptyOutDir:false,
rollupOptions: {
input: join(widgetsDir, widget, 'main.ts'),
output: {
entryFileNames: widget + '.js',
format: 'iife'
}
},
},
})
}
+1
View File
@@ -0,0 +1 @@
{"success": true, "config": {"captcha": false, "submitUrl": "/error"}}
+1
View File
@@ -0,0 +1 @@
{"success": true, "config": {"captcha": false}}
+85
View File
@@ -0,0 +1,85 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Feedback Widget Demo</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background: #f5f5f5;
}
* {
color: #666;
text-align: center;
}
h1 {
color: #333;
text-align: center;
}
.controls {
text-align: center;
margin: 20px 0;
}
button {
margin: 0 10px;
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #0056b3;
}
</style>
</head>
<body>
<a href="/">Back to index</a>
<h1>Feedback Widget Demo</h1>
<script type="module" src="../src/widgets/feedback/main.ts"></script>
<script>
window._stmsg_widget = window._stmsg_widget || [];
_stmsg_widget.push(["feedback", "init", {
"title": "Do you have any feedback?",
"api": "http://localhost:8905/demo/api.json?",
"channel": "xyz-xyz-xyz"
}]);
</script>
<button onclick='_stmsg_widget.push(["feedback", "close"]);'>Close</button>
<button onclick='
_stmsg_widget.push(["feedback", "init", {
"email": "test@test.com",
"title": "Do you have any feedback?",
"api": "http://localhost:8905/demo/api.json?",
"channel": "xyz-xyz-xyz"
}]);
'>Re-init with email</button>
<br/><br/>
<button onclick='
_stmsg_widget.push(["feedback", "init", {
"email": "test@test.com",
"title": "Do you have any feedback?",
"api": "/error",
"channel": "xyz-xyz-xyz"
}]);
'>Re-init with loading error</button>
<button onclick='
_stmsg_widget.push(["feedback", "init", {
"email": "test@test.com",
"title": "Do you have any feedback?",
"api": "http://localhost:8905/demo/api-error.json?",
"channel": "xyz-xyz-xyz"
}]);
'>Re-init with submit error</button>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loader Widget Demo</title>
<style>
* {
color: #666;
text-align: center;
}
html {
font-family: Arial, sans-serif;
padding: 0px;
background: #f5f5f5;
}
button {
margin: 0 10px;
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #0056b3;
}
</style>
</head>
<body>
<a href="/">Back to index</a>
<h1>Loader Widget Demo</h1>
<button onclick="document.documentElement.style.backgroundColor = '#000'">Make background dark</button>
<button onclick="document.documentElement.style.backgroundColor = '#fff'">Make background light</button>
<button onclick='
_stmsg_widget.push(["loader", "init", {
"params": {
"title": "Do you have any feedback?",
"api": "http://localhost:8905/demo/api.json?",
"channel": "xyz-xyz-xyz"
},
"script": "/error",
"scriptType": "module",
"widget": "feedback",
"label": "Feedback Widget"
}]);
'>Re-init with loading error</button>
<script type="module" src="../src/widgets/loader/main.ts"></script>
<script>
window._stmsg_widget = window._stmsg_widget || [];
_stmsg_widget.push(["loader", "init", {
"params": {
"title": "Do you have any feedback?",
"api": "http://localhost:8905/demo/api.json?",
"channel": "xyz-xyz-xyz",
"successText2": "We'll get back to you soon."
},
"script": "../src/widgets/feedback/main.ts",
"scriptType": "module",
"widget": "feedback",
"label": "Feedback Widget"
}]);
</script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Widgets demo</title>
</head>
<body>
<h1>Widgets demo</h1>
<ul>
<li><a href="demo/feedback.html">Feedback</a></li>
<li><a href="demo/loader.html">Loader</a></li>
</ul>
</body>
</html>
+1044
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@gouvfr-lasuite/widgets",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 8905",
"build": "tsc && node build.js",
"preview": "vite preview --host 0.0.0.0 --port 8905"
},
"devDependencies": {
"typescript": "5.9.2",
"vite": "7.1.7"
}
}
+11
View File
@@ -0,0 +1,11 @@
const NAMESPACE = `stmsg-widget`;
export const triggerEvent = (widgetName: string, eventName: string, detail?: any, root?: any) => {
return (root || document).dispatchEvent(new CustomEvent(`${NAMESPACE}-${widgetName}-${eventName}`, detail ? { detail } : undefined));
}
export const listenEvent = (widgetName: string, eventName: string, root: any, once: boolean, callback: (data: any) => void) => {
const cb = (e: CustomEvent) => callback(e.detail);
(root || document).addEventListener(`${NAMESPACE}-${widgetName}-${eventName}`, cb, once ? { once: true } : undefined);
return () => (root || document).removeEventListener(`${NAMESPACE}-${widgetName}-${eventName}`, cb, once ? { once: true } : undefined);
}
+72
View File
@@ -0,0 +1,72 @@
import { triggerEvent } from './events';
type WidgetEvent = [string, string, any];
type EventArray = Array<WidgetEvent> & { _loaded?: Record<string, number> };
declare global {
var _stmsg_widget: EventArray;
}
// This could have been an enum but we want to support erasableSyntaxOnly TS settings
export const STATE_NOT_LOADED = 0;
export const STATE_LOADING = 1;
export const STATE_LOADED = 2;
export const getLoaded = (widgetName: string) => {
return window._stmsg_widget?._loaded?.[widgetName];
}
export const setLoaded = (widgetName: string, status: number) => {
if (!window._stmsg_widget?._loaded) return;
window._stmsg_widget._loaded[widgetName] = status;
}
// Replace the push method of the _stmsg_widget array used for communication between the widget and the page
export const installHook = (widgetName: string) => {
if (!window._stmsg_widget) {
window._stmsg_widget = [] as EventArray;
}
const W = window._stmsg_widget;
// Keep track of the loaded state of each widget
if (!W._loaded) {
W._loaded = {} as Record<string, number>;
}
if (getLoaded(widgetName) !== STATE_LOADED) {
// Replace the push method of the _stmsg_widget array used for communication between the widget and the page
W.push = ((...elts: WidgetEvent[]): number => {
for (const elt of elts) {
// If the target widget is loaded, fire the event
if (getLoaded(elt[0]) === STATE_LOADED) {
triggerEvent(elt[0], elt[1], elt[2]);
} else {
W[W.length] = elt;
}
}
return W.length;
}) as typeof Array.prototype.push;
setLoaded(widgetName, STATE_LOADED);
// Empty the existing array and re-push all events that were received before the hook was installed
for (const evt of W.splice(0, W.length)) {
W.push(evt);
}
}
// Finally, fire an event to signal that we are loaded
triggerEvent(widgetName, 'loaded');
}
// Loads another widget from the same directory
export const injectScript = (url: string, type: string = "") => {
const newScript = document.createElement('script');
newScript.src = url;
newScript.type = type;
newScript.defer = true;
document.body.appendChild(newScript);
}
+30
View File
@@ -0,0 +1,30 @@
// Shared utility for creating shadow DOM widgets
export function createShadowWidget(widgetName: string, htmlContent: string, cssContent: string): HTMLDivElement {
const id = `stmsg-widget-${widgetName}-shadow`;
// Check if widget already exists
const existingWidget = document.getElementById(id);
if (existingWidget) {
existingWidget.remove();
}
// Create container element
const container = document.createElement('div');
container.id = id;
// Create shadow root
const shadow = container.attachShadow({ mode: 'open' });
// Create style element for scoped CSS
const style = document.createElement('style');
style.textContent = cssContent;
// Create content element
const content = document.createElement('div');
content.innerHTML = htmlContent;
// Append style and content to shadow DOM
shadow.appendChild(style);
shadow.appendChild(content);
return container;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1 @@
+164
View File
@@ -0,0 +1,164 @@
import styles from './styles.css?inline'
import { createShadowWidget } from '../../shared/shadow-dom'
import { installHook } from '../../shared/script'
import { listenEvent, triggerEvent } from '../../shared/events'
const widgetName = "feedback";
type ConfigData = {
title?: string;
placeholder?: string;
emailPlaceholder?: string;
submitText?: string;
successText?: string;
successText2?: string;
submitUrl?: string;
};
type ConfigResponse = {
success?: boolean;
detail?: string;
captcha?: boolean;
config?: ConfigData;
};
listenEvent(widgetName, 'init', null, false, async (args) => {
if (!args.api || !args.channel) {
console.error("Feedback widget requires an API URL and a channel ID");
return;
}
let configData: ConfigData | undefined;
try {
const config = await fetch(`${args.api}config/`, {
'headers': {
'X-Channel-ID': args.channel
}
});
const configResponse = await config.json() as ConfigResponse;
if (!configResponse.success) throw new Error(configResponse.detail || 'Unknown error');
if (configResponse.captcha) throw new Error('Captcha is not supported yet');
configData = configResponse.config;
} catch (error) {
console.error("Error fetching config", error);
triggerEvent(widgetName, 'closed');
return;
}
const title = args.title || configData?.title || 'Feedback';
const placeholder = args.placeholder || configData?.placeholder || 'Share your feedback...';
const emailPlaceholder = args.emailPlaceholder || configData?.emailPlaceholder || 'Your email...';
const submitText = args.submitText || configData?.submitText || 'Send Feedback';
const successText = args.successText || configData?.successText || 'Thank you for your feedback!';
const successText2 = args.successText2 || configData?.successText2;
const htmlContent = `<div id="wrapper">` +
`<div id="header">` +
`<span id="title"></span>` +
`<button id="close" aria-label="Close the feedback widget" tabindex="4">&times;</button>` +
`</div>` +
`<form id="content">` +
`<textarea id="feedback-text" autocomplete="off" required tabindex="1"></textarea>` +
`<input type="email" id="email" autocomplete="email" required tabindex="2">` +
`<button type="submit" id="submit" tabindex="3"></button>` +
`<div id="status" aria-live="polite" role="status"></div>` +
`</form>` +
`</div>`;
// Create shadow DOM widget
const shadowContainer = createShadowWidget(widgetName, htmlContent, styles);
const shadowRoot = shadowContainer.shadowRoot!;
const titleSpan = shadowRoot.querySelector<HTMLSpanElement>('#title')!
const submitBtn = shadowRoot.querySelector<HTMLButtonElement>('#submit')!
const feedbackText = shadowRoot.querySelector<HTMLTextAreaElement>('#feedback-text')!
const statusDiv = shadowRoot.querySelector<HTMLDivElement>('#status')!
const closeBtn = shadowRoot.querySelector<HTMLButtonElement>('#close')!
const emailInput = shadowRoot.querySelector<HTMLInputElement>('#email')!
const form = shadowRoot.querySelector<HTMLFormElement>('form')!
titleSpan.textContent = title;
feedbackText.placeholder = placeholder;
emailInput.placeholder = emailPlaceholder;
submitBtn.textContent = submitText;
if (args.email) {
emailInput.remove();
}
const setStatus = (status: string, success: boolean) => {
statusDiv.innerHTML = '';
const statusSpan = document.createElement('div');
statusSpan.id = 'statusmsg';
statusSpan.classList.add(success ? 'success' : 'error');
statusSpan.textContent = status;
statusDiv.appendChild(statusSpan);
if (successText2) {
const statusSpan2 = document.createElement('div');
statusSpan2.id = 'statusmsg2';
statusSpan2.classList.add('success');
statusSpan2.textContent = successText2;
statusDiv.appendChild(statusSpan2);
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const message = feedbackText.value.trim()
const email = args.email || emailInput.value.trim();
try {
if (!message) {
feedbackText.focus();
throw new Error("Missing value");
}
if (!email) {
emailInput.focus();
throw new Error("Missing value");
}
const ret = await fetch(configData?.submitUrl || `${args.api}deliver/`, {
'method': 'POST',
'headers': {
'Content-Type': 'application/json',
'X-Channel-ID': args.channel
},
'body': JSON.stringify({ textBody: message, email })
});
let retData;
try {
retData = await ret.json();
} catch (error) {
throw new Error('Invalid response from server');
}
if (!retData.success) throw new Error(retData.detail || 'Unknown error');
setStatus(successText, true);
feedbackText.remove();
emailInput.remove();
submitBtn.remove();
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Unknown error', false);
}
});
const closeWidget = () => {
shadowRoot.host.remove();
triggerEvent(widgetName, 'closed');
}
closeBtn.addEventListener('click', closeWidget);
listenEvent(widgetName, 'close', null, false, closeWidget);
document.body.appendChild(shadowContainer);
feedbackText.focus();
triggerEvent(widgetName, 'opened');
});
installHook(widgetName);
+111
View File
@@ -0,0 +1,111 @@
/* Widget styles */
#wrapper {
position: fixed;
bottom: 100px;
right: 20px;
z-index: 1000;
width: 350px;
height: 400px;
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
}
#header {
background: #000091;
color: white;
padding: 16px;
font-weight: 600;
font-size: 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
#close {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 18px;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
#close:hover {
opacity: 0.8;
}
#content {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
#email {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 12px;
font-family: inherit;
font-size: 14px;
}
textarea {
flex: 1;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 12px;
font-family: inherit;
font-size: 14px;
resize: none;
}
button[type="submit"] {
background: #000091;
color: white;
border: none;
border-radius: 8px;
padding: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button[type="submit"]:hover {
background: #1212ff;
}
#status {
font-size: 14px;
font-weight: 500;
text-align: center;
}
.success {
color: #28a745;
display:table;
margin:40px auto;
}
.error {
color: #dc3545;
}
/* Responsive */
@media (max-width: 420px) {
#wrapper {
width: 100%;
right:0px;
border-radius: 0;
bottom:75px;
}
}
+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" transform="matrix(-1, 0, 0, 1, 0, 0)"><g stroke-width="0"></g><g stroke-linecap="round" stroke-linejoin="round"></g><g><path d="M7 9H17M7 13H17M21 20L17.6757 18.3378C17.4237 18.2118 17.2977 18.1488 17.1656 18.1044C17.0484 18.065 16.9277 18.0365 16.8052 18.0193C16.6672 18 16.5263 18 16.2446 18H6.2C5.07989 18 4.51984 18 4.09202 17.782C3.71569 17.5903 3.40973 17.2843 3.21799 16.908C3 16.4802 3 15.9201 3 14.8V7.2C3 6.07989 3 5.51984 3.21799 5.09202C3.40973 4.71569 3.71569 4.40973 4.09202 4.21799C4.51984 4 5.0799 4 6.2 4H17.8C18.9201 4 19.4802 4 19.908 4.21799C20.2843 4.40973 20.5903 4.71569 20.782 5.09202C21 5.51984 21 6.0799 21 7.2V20Z" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g></svg>

After

Width:  |  Height:  |  Size: 813 B

+79
View File
@@ -0,0 +1,79 @@
import styles from './styles.css?inline'
import { createShadowWidget } from '../../shared/shadow-dom'
import icon from './icon.svg?raw'
import { injectScript, installHook, getLoaded, setLoaded, STATE_LOADED, STATE_LOADING } from '../../shared/script'
import { triggerEvent, listenEvent } from '../../shared/events'
const widgetName = "loader";
// The init event is sent from the embedding code
listenEvent(widgetName, 'init', null, false, (args) => {
const targetWidget = args.widget || 'feedback';
const htmlContent = `<div><button type="button">${icon}</button></div>`;
// Create shadow DOM widget
const shadowContainer = createShadowWidget(widgetName, htmlContent, styles)
const shadowRoot = shadowContainer.shadowRoot!;
const btn = shadowRoot.querySelector<HTMLButtonElement>('button')!
const ariaOpen = () => {
btn.setAttribute('aria-label', String(args.closeLabel || 'Close widget'))
btn.setAttribute('aria-expanded', 'true')
// TODO: How could we set the aria-controls attribute too, given that we
// have no id for the widget? Should we ask for it via an event?
}
const ariaClose = () => {
btn.setAttribute('aria-label', String(args.label || 'Load widget'))
btn.setAttribute('aria-expanded', 'false')
}
ariaClose();
listenEvent(targetWidget, 'closed', null, false, () => {
btn.classList.remove('opened')
ariaClose();
})
listenEvent(targetWidget, 'opened', null, false, () => {
btn.classList.add('opened')
ariaOpen();
})
btn.addEventListener('click', () => {
if (btn.classList.contains('opened')) {
triggerEvent(targetWidget, 'close');
return;
}
const loadTimeout = setTimeout(() => {
btn.classList.remove('loading');
}, 10000)
// Add loading state to the UI
btn.classList.add('loading')
const loadedCallback = () => {
clearTimeout(loadTimeout)
btn.classList.remove('loading')
window._stmsg_widget.push([targetWidget, "init", args.params]);
}
if (getLoaded(targetWidget) === STATE_LOADED) {
loadedCallback();
} else {
listenEvent(targetWidget, 'loaded', null, true, loadedCallback);
// If it isn't even loading, we need to inject the script
if (!getLoaded(targetWidget)) {
injectScript(args.script, args.scriptType || "");
setLoaded(targetWidget, STATE_LOADING);
}
}
})
document.body.appendChild(shadowContainer);
});
installHook(widgetName);
+98
View File
@@ -0,0 +1,98 @@
/* Widget styles */
div {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
}
button {
width: 60px;
height: 60px;
border-radius: 50%;
background: #000091;
border: none;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 145, 0.3);
transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
padding:0;
margin:0;
}
svg {
width: 35px;
height: 35px;
}
button:focus-visible {
outline: 3px solid #0a76f6;
outline-offset: 2px;
}
button:hover {
background: #1212ff;
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(18, 18, 255, 0.4);
}
button:active {
transform: scale(0.95);
}
button.loading {
background: #2323ff;
}
button.loading::after {
content: '';
width: 25px;
height: 25px;
border: 3px solid transparent;
border-top: 3px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
button.loading svg {
display: none;
}
button.opened svg {
display:none;
}
button.opened::after {
content: '+';
font-size: 50px;
color: white;
height:60px;
line-height: 60px;
font-family: Arial;
transform: rotate(45deg);
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (max-width: 420px) {
button {
width: 40px;
height: 40px;
}
svg {
width: 25px;
height: 25px;
}
div {
right:15px;
bottom:15px;
}
button.opened::after {
font-size: 40px;
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from 'vite'
export default defineConfig({
})