Skip to content

Commit e94c731

Browse files
committed
✨(webhooks) add new Webhooks Channel to create messages from API
1 parent 7660eb9 commit e94c731

File tree

4 files changed

+425
-1
lines changed

4 files changed

+425
-1
lines changed

compose.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ services:
5252
- "8917:1025"
5353

5454
objectstorage:
55-
user: ${DOCKER_USER:-1000}
55+
# user: ${DOCKER_USER:-1000}
5656
image: minio/minio
5757
environment:
5858
- MINIO_ROOT_USER=st-messages
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""Webhook channel implementation for receiving messages from external services."""
2+
3+
import logging
4+
from html import escape as html_escape
5+
from secrets import compare_digest
6+
7+
from django.core.exceptions import ValidationError
8+
from django.core.validators import validate_email
9+
from django.utils import timezone
10+
11+
from drf_spectacular.utils import extend_schema
12+
from rest_framework import status, viewsets
13+
from rest_framework.authentication import BaseAuthentication
14+
from rest_framework.decorators import action
15+
from rest_framework.exceptions import AuthenticationFailed
16+
from rest_framework.response import Response
17+
18+
from core import models
19+
from core.api.permissions import IsAuthenticated
20+
from core.mda.inbound import deliver_inbound_message
21+
from core.mda.rfc5322 import compose_email
22+
23+
logger = logging.getLogger(__name__)
24+
25+
26+
class WebhookAuthentication(BaseAuthentication):
27+
"""
28+
Custom authentication for webhook endpoints with configurable auth methods.
29+
Currently supports API Key authentication.
30+
Returns None or (user, auth)
31+
"""
32+
33+
def authenticate(self, request):
34+
# Get channel ID from header
35+
channel_id = request.headers.get("X-Channel-ID")
36+
if not channel_id:
37+
raise AuthenticationFailed("Missing X-Channel-ID header")
38+
39+
try:
40+
channel = models.Channel.objects.get(id=channel_id)
41+
except models.Channel.DoesNotExist as e:
42+
raise AuthenticationFailed("Invalid channel ID") from e
43+
44+
# Get authentication method from channel settings
45+
auth_method = (channel.settings or {}).get("auth_method", "api_key")
46+
47+
if auth_method == "api_key":
48+
return self._authenticate_api_key(request, channel)
49+
50+
raise AuthenticationFailed(f"Unsupported authentication method: {auth_method}")
51+
52+
def _authenticate_api_key(self, request, channel):
53+
"""Authenticate using API key from channel settings."""
54+
api_key = request.headers.get("X-API-Key")
55+
if not api_key:
56+
raise AuthenticationFailed("Missing X-API-Key header")
57+
58+
expected_api_key = (channel.settings or {}).get("api_key")
59+
if not expected_api_key:
60+
raise AuthenticationFailed("API key not configured for this channel")
61+
62+
# Use constant-time comparison to prevent timing attacks
63+
if not compare_digest(api_key, expected_api_key):
64+
raise AuthenticationFailed("Invalid API key")
65+
66+
return (None, {"channel": channel, "auth_method": "api_key"})
67+
68+
def authenticate_header(self, request):
69+
"""Return the header to be used in the WWW-Authenticate response header."""
70+
return 'ApiKey realm="Webhook"'
71+
72+
73+
class InboundWebhookViewSet(viewsets.GenericViewSet):
74+
"""Handles incoming messages from webhooks with configurable authentication."""
75+
76+
# Channel metadata
77+
CHANNEL_TYPE = "webhook"
78+
CHANNEL_DESCRIPTION = "Generic webhook integration"
79+
80+
permission_classes = [IsAuthenticated]
81+
authentication_classes = [WebhookAuthentication]
82+
83+
@extend_schema(exclude=True)
84+
@action(
85+
detail=False,
86+
methods=["post"],
87+
url_path="deliver",
88+
url_name="inbound-webhook-deliver",
89+
)
90+
def deliver(self, request):
91+
"""Handle incoming webhook message."""
92+
# TODO: Add rate limiting/throttling
93+
94+
data = request.data
95+
auth_data = request.auth
96+
channel = auth_data["channel"]
97+
98+
# Extract message data with standard field names
99+
sender_email = data.get("email")
100+
message_text = data.get("message", "")
101+
subject = data.get("subject", "Message from webhook")
102+
sender_name = data.get("name", "")
103+
104+
# Validate required fields
105+
if not sender_email:
106+
return Response(
107+
{"detail": "Missing email"}, status=status.HTTP_400_BAD_REQUEST
108+
)
109+
110+
# Validate the sender email format
111+
try:
112+
validate_email(sender_email)
113+
except ValidationError:
114+
return Response(
115+
{"detail": "Invalid email format"}, status=status.HTTP_400_BAD_REQUEST
116+
)
117+
118+
if not message_text:
119+
return Response(
120+
{"detail": "Missing message"}, status=status.HTTP_400_BAD_REQUEST
121+
)
122+
123+
# Get the target mailbox
124+
mailbox = channel.mailbox
125+
if not mailbox:
126+
return Response(
127+
{"detail": "No mailbox configured for this channel"},
128+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
129+
)
130+
131+
# Determine target email and name
132+
if mailbox.contact:
133+
target_email = mailbox.contact.email
134+
target_name = mailbox.contact.name
135+
else:
136+
target_email = str(mailbox)
137+
target_name = str(mailbox)
138+
139+
# Build sender information
140+
sender_info = {"email": sender_email}
141+
if sender_name:
142+
sender_info["name"] = sender_name
143+
144+
# Sanitize headers to prevent header injection
145+
def sanitize_header(header: str) -> str:
146+
return header.replace("\r", "").replace("\n", "")[0:1000]
147+
148+
# Add webhook-specific headers
149+
prepend_headers = [("X-StMsg-Sender-Auth", "webhook")]
150+
151+
# Add source information
152+
if request.META.get("HTTP_USER_AGENT"):
153+
prepend_headers.append(
154+
(
155+
"X-StMsg-Webhook-User-Agent",
156+
sanitize_header(request.META.get("HTTP_USER_AGENT")),
157+
)
158+
)
159+
160+
if request.META.get("HTTP_REFERER"):
161+
prepend_headers.append(
162+
(
163+
"X-StMsg-Webhook-Referer",
164+
sanitize_header(request.META.get("HTTP_REFERER")),
165+
)
166+
)
167+
168+
prepend_headers.append(
169+
(
170+
"Received",
171+
f"from webhook ({sanitize_header(request.META.get('REMOTE_ADDR'))})",
172+
)
173+
)
174+
175+
# Build a JMAP-like structured format
176+
parsed_email = {
177+
"subject": subject,
178+
"from": sender_info,
179+
"to": [{"name": target_name, "email": target_email}],
180+
"date": timezone.now(),
181+
"htmlBody": [{"content": html_escape(message_text).replace("\n", "<br/>")}],
182+
"textBody": [{"content": message_text}],
183+
}
184+
185+
# Deliver the message
186+
delivered = deliver_inbound_message(
187+
target_email,
188+
parsed_email,
189+
compose_email(parsed_email, prepend_headers=prepend_headers),
190+
channel=channel,
191+
)
192+
193+
if not delivered:
194+
return Response(
195+
{"detail": "Failed to deliver message"},
196+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
197+
)
198+
199+
logger.info(
200+
"Successfully created message from webhook for channel %s, sender: %s",
201+
channel.id,
202+
sender_email,
203+
)
204+
205+
return Response(
206+
{
207+
"success": True,
208+
"message": "Message delivered successfully",
209+
}
210+
)

0 commit comments

Comments
 (0)