mirror of
https://github.com/suitenumerique/messages.git
synced 2026-09-13 13:27:45 +02:00
✨(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:
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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=
|
||||
@@ -0,0 +1 @@
|
||||
# Nothing yet!
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
+34
-37
@@ -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'"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+43
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
+29
-29
@@ -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}",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
-58
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Html, Head, Main, NextScript } from "next/document";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
|
||||
export default function Document() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"success": true, "config": {"captcha": false, "submitUrl": "/error"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"success": true, "config": {"captcha": false}}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Generated
+1044
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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">×</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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
})
|
||||
Reference in New Issue
Block a user