Skip to content

✨(channels) add multipe inbound channels & alpha frontend widgets #301

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -73,7 +67,8 @@ create-env-files: \
env.d/development/backend.local \
env.d/development/frontend.local \
env.d/development/mta-in.local \
env.d/development/mta-out.local
env.d/development/mta-out.local \
env.d/development/widgets.local
.PHONY: create-env-files

bootstrap: ## Prepare the project for local development
Expand Down Expand Up @@ -449,6 +444,26 @@ front-api-update: ## Update the frontend API client
@$(COMPOSE) run --rm 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


api-update: ## Update the OpenAPI schema then frontend API client
api-update: \
back-api-update \
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
22 changes: 19 additions & 3 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,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
Expand All @@ -221,7 +221,7 @@ services:
profiles:
- frontend-tools
build:
dockerfile: ./src/frontend/Dockerfile.dev
dockerfile: ./src/frontend/Dockerfile
volumes:
- ./src/backend/core/api/openapi.json:/home/backend/core/api/openapi.json
- ./src/frontend/:/home/frontend/
Expand All @@ -232,11 +232,27 @@ services:
- frontend-tools
platform: linux/amd64
build:
dockerfile: ./src/frontend/Dockerfile.dev
dockerfile: ./src/frontend/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:
Expand Down
Empty file.
24 changes: 24 additions & 0 deletions src/backend/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,30 @@ class MailboxAdmin(admin.ModelAdmin):
actions = [reset_keycloak_password_action]


@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")

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"""
Expand Down
46 changes: 45 additions & 1 deletion src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from rest_framework.exceptions import PermissionDenied

from core import models

from core.channels import list_channel_types

class IntegerChoicesField(serializers.ChoiceField):
"""
Expand Down Expand Up @@ -802,3 +802,47 @@ 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, data):
"""Validate channel data."""
mailbox = data.get("mailbox")
maildomain = data.get("maildomain")
channel_type = data.get("type")

# 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."
)

# Validate that the channel type exists
if channel_type:
if channel_type not in list_channel_types().keys():
raise serializers.ValidationError(
f"Invalid channel type"
)

return data
46 changes: 46 additions & 0 deletions src/backend/core/api/viewsets/channel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Admin ViewSet for Channel management."""

from django.db.models import F, Q
from django.shortcuts import get_object_or_404

from drf_spectacular.utils import extend_schema
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response

from core import models
from core.api import permissions as core_permissions
from core.api import serializers as core_serializers


@extend_schema(tags=["channels"])
class AdminChannelViewSet(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
ViewSet for managing Channels.
Endpoint: /channels/
"""

serializer_class = core_serializers.ChannelSerializer
permission_classes = [core_permissions.IsAuthenticated]

def get_queryset(self):
"""Restrict results to channels the user has admin access to."""
user = self.request.user
if not user or not user.is_authenticated:
return models.Channel.objects.none()

if user.is_superuser:
return models.Channel.objects.all().order_by("-created_at")

# Only mailbox admins can access channels
return models.Channel.objects.filter(
Q(mailbox__accesses__user=user, mailbox__accesses__role=models.MailboxRoleChoices.ADMIN) |
Q(maildomain__accesses__user=user, maildomain__accesses__role=models.MailDomainAccessRoleChoices.ADMIN)
).distinct().order_by("-created_at")
98 changes: 98 additions & 0 deletions src/backend/core/api/viewsets/inbound.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Generic inbound API for handling different channel types."""

import logging
from typing import Dict, Any

from django.conf import settings
from django.shortcuts import get_object_or_404
from django.core.exceptions import ValidationError


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.parsers import JSONParser, BaseParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

from core import models
from core.channels import load_channel

logger = logging.getLogger(__name__)


class InboundViewSet(viewsets.GenericViewSet):
"""Generic ViewSet for handling inbound messages from various channels."""

permission_classes = [IsAuthenticated] # All channels require authentication
parser_classes = [JSONParser, BaseParser]

def get_authentication_classes(self):
"""Return authentication classes based on channel type."""
# Get channel type from URL parameters
channel_type = self.kwargs.get('channel_type')

# Get the processor class for this channel type
processor_class = load_channel(channel_type)
if not processor_class:
raise ValueError(f"Unknown channel type")

return processor_class.get_authentication_classes()

@action(
detail=False,
methods=["post"],
url_path="check",
url_name="inbound-check"
)
def check(self, request, channel_type=None):
"""
Generic check endpoint.
"""
try:
# Get the appropriate processor for this channel type
processor_class = load_channel(channel_type)
processor = processor_class()

# Let the processor handle the check logic
return processor.check(request)

except Exception as e:
logger.error("Error in check operation: %s", str(e))
return Response(
{
"status": "error",
"detail": "Error in check operation"
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)


@action(
detail=False,
methods=["post"],
url_path="deliver",
url_name="inbound-deliver"
)
def deliver(self, request, channel_type=None):
"""
Generic deliver endpoint.
"""
try:
# Get the appropriate processor for this channel type
processor_class = load_channel(channel_type)
processor = processor_class()

# Let the processor handle the delivery logic
return processor.deliver(request)

except Exception as e:
logger.error("Error in deliver operation: %s", str(e))
return Response(
{
"status": "error",
"detail": "Error in deliver operation"
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
51 changes: 0 additions & 51 deletions src/backend/core/authentication/__init__.py
Original file line number Diff line number Diff line change
@@ -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'"
Loading
Loading