Skip to content

Commit 93ce158

Browse files
authored
Merge branch 'main' into feat/gsoc-page
2 parents db642da + c2565fb commit 93ce158

File tree

3 files changed

+269
-1
lines changed

3 files changed

+269
-1
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ SLACK_ID_CLIENT=your_slack_client_id_here
3131
SLACK_SECRET_CLIENT=your_slack_client_secret_here
3232
SLACK_BOT_TOKEN=your_slack_bot_token_here
3333
SLACK_SIGNING_SECRET=your_slack_signing_secret_here
34+
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
3435

3536
#BlueSky User Details
3637
BLUESKY_USERNAME=example.bsky.social
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import json
2+
from unittest.mock import patch
3+
4+
from django.contrib.auth.models import User
5+
from django.test import Client, TestCase
6+
from django.urls import reverse
7+
8+
from website.models import Domain, UserProfile
9+
10+
11+
class SendGridWebhookTestCase(TestCase):
12+
"""Test SendGrid webhook handling and Slack notification"""
13+
14+
def setUp(self):
15+
"""Set up test data"""
16+
self.client = Client()
17+
self.webhook_url = reverse("inbound_event_webhook_callback")
18+
19+
# Create test user with email
20+
self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass")
21+
self.user_profile = UserProfile.objects.get(user=self.user)
22+
23+
# Create test domain
24+
self.domain = Domain.objects.create(
25+
name="example.com",
26+
url="https://example.com",
27+
email="test@example.com",
28+
)
29+
30+
@patch("website.views.organization.requests.post")
31+
def test_webhook_sends_to_slack_with_bounce_event(self, mock_post):
32+
"""Test that webhook sends bounce event to Slack"""
33+
mock_post.return_value.status_code = 200
34+
mock_post.return_value.raise_for_status = lambda: None
35+
36+
payload = [
37+
{
38+
"email": "test@example.com",
39+
"event": "bounce",
40+
"reason": "Invalid mailbox",
41+
"timestamp": "2024-01-01 12:00:00",
42+
"sg_message_id": "test-message-id",
43+
}
44+
]
45+
46+
with patch.dict("os.environ", {"SLACK_WEBHOOK_URL": "https://hooks.slack.com/test"}):
47+
response = self.client.post(
48+
self.webhook_url,
49+
data=json.dumps(payload),
50+
content_type="application/json",
51+
)
52+
53+
self.assertEqual(response.status_code, 200)
54+
self.assertEqual(response.json()["detail"], "Inbound Sendgrid Webhook received")
55+
56+
# Verify Slack webhook was called
57+
self.assertTrue(mock_post.called)
58+
call_args = mock_post.call_args
59+
self.assertEqual(call_args[0][0], "https://hooks.slack.com/test")
60+
61+
# Verify the payload sent to Slack
62+
slack_payload = call_args[1]["json"]
63+
self.assertIn("blocks", slack_payload)
64+
self.assertEqual(len(slack_payload["blocks"]), 1)
65+
66+
# Verify the message contains event details
67+
text = slack_payload["blocks"][0]["text"]["text"]
68+
self.assertIn("BOUNCE", text)
69+
self.assertIn("test@example.com", text)
70+
self.assertIn("Invalid mailbox", text)
71+
72+
@patch("website.views.organization.requests.post")
73+
def test_webhook_sends_to_slack_with_click_event(self, mock_post):
74+
"""Test that webhook sends click event to Slack"""
75+
mock_post.return_value.status_code = 200
76+
mock_post.return_value.raise_for_status = lambda: None
77+
78+
payload = [
79+
{
80+
"email": "test@example.com",
81+
"event": "click",
82+
"url": "https://example.com/link",
83+
"timestamp": "2024-01-01 12:00:00",
84+
}
85+
]
86+
87+
with patch.dict("os.environ", {"SLACK_WEBHOOK_URL": "https://hooks.slack.com/test"}):
88+
response = self.client.post(
89+
self.webhook_url,
90+
data=json.dumps(payload),
91+
content_type="application/json",
92+
)
93+
94+
self.assertEqual(response.status_code, 200)
95+
96+
# Verify Slack webhook was called
97+
self.assertTrue(mock_post.called)
98+
slack_payload = mock_post.call_args[1]["json"]
99+
text = slack_payload["blocks"][0]["text"]["text"]
100+
self.assertIn("CLICK", text)
101+
self.assertIn("https://example.com/link", text)
102+
103+
@patch("website.views.organization.logger")
104+
def test_webhook_without_slack_url_logs_debug(self, mock_logger):
105+
"""Test that webhook logs debug message when SLACK_WEBHOOK_URL is not set"""
106+
payload = [
107+
{
108+
"email": "test@example.com",
109+
"event": "open",
110+
"timestamp": "2024-01-01 12:00:00",
111+
}
112+
]
113+
114+
with patch.dict("os.environ", {}, clear=True):
115+
response = self.client.post(
116+
self.webhook_url,
117+
data=json.dumps(payload),
118+
content_type="application/json",
119+
)
120+
121+
self.assertEqual(response.status_code, 200)
122+
123+
# Verify debug log was called
124+
mock_logger.debug.assert_called_with("SLACK_WEBHOOK_URL not configured, skipping Slack notification")
125+
126+
@patch("website.views.organization.requests.post")
127+
@patch("website.views.organization.logger")
128+
def test_webhook_handles_slack_error_gracefully(self, mock_logger, mock_post):
129+
"""Test that webhook handles Slack errors without failing the webhook"""
130+
# Simulate Slack webhook error
131+
mock_post.side_effect = Exception("Slack is down")
132+
133+
payload = [
134+
{
135+
"email": "test@example.com",
136+
"event": "delivered",
137+
"timestamp": "2024-01-01 12:00:00",
138+
}
139+
]
140+
141+
with patch.dict("os.environ", {"SLACK_WEBHOOK_URL": "https://hooks.slack.com/test"}):
142+
response = self.client.post(
143+
self.webhook_url,
144+
data=json.dumps(payload),
145+
content_type="application/json",
146+
)
147+
148+
# Webhook should still succeed even if Slack fails
149+
self.assertEqual(response.status_code, 200)
150+
self.assertEqual(response.json()["detail"], "Inbound Sendgrid Webhook received")
151+
152+
# Verify error was logged
153+
mock_logger.error.assert_called()
154+
155+
@patch("website.views.organization.requests.post")
156+
def test_webhook_sends_multiple_events_to_slack(self, mock_post):
157+
"""Test that webhook sends multiple events to Slack"""
158+
mock_post.return_value.status_code = 200
159+
mock_post.return_value.raise_for_status = lambda: None
160+
161+
payload = [
162+
{
163+
"email": "test1@example.com",
164+
"event": "delivered",
165+
"timestamp": "2024-01-01 12:00:00",
166+
},
167+
{
168+
"email": "test2@example.com",
169+
"event": "open",
170+
"timestamp": "2024-01-01 12:05:00",
171+
},
172+
]
173+
174+
with patch.dict("os.environ", {"SLACK_WEBHOOK_URL": "https://hooks.slack.com/test"}):
175+
response = self.client.post(
176+
self.webhook_url,
177+
data=json.dumps(payload),
178+
content_type="application/json",
179+
)
180+
181+
self.assertEqual(response.status_code, 200)
182+
183+
# Verify Slack webhook was called twice (once for each event)
184+
self.assertEqual(mock_post.call_count, 2)
185+
186+
def test_webhook_updates_user_profile(self):
187+
"""Test that webhook still updates user profile as before"""
188+
payload = [
189+
{
190+
"email": "test@example.com",
191+
"event": "bounce",
192+
"reason": "Invalid mailbox",
193+
"timestamp": "2024-01-01 12:00:00",
194+
}
195+
]
196+
197+
with patch.dict("os.environ", {}, clear=True):
198+
response = self.client.post(
199+
self.webhook_url,
200+
data=json.dumps(payload),
201+
content_type="application/json",
202+
)
203+
204+
self.assertEqual(response.status_code, 200)
205+
206+
# Verify user profile was updated
207+
self.user_profile.refresh_from_db()
208+
self.assertEqual(self.user_profile.email_status, "bounce")
209+
self.assertEqual(self.user_profile.email_last_event, "bounce")
210+
self.assertEqual(self.user_profile.email_bounce_reason, "Invalid mailbox")

website/views/organization.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import ipaddress
22
import json
33
import logging
4+
import os
45
import re
56
import time
67
from collections import defaultdict
@@ -1184,7 +1185,9 @@ def get_success_url(self):
11841185
class InboundParseWebhookView(View):
11851186
def post(self, request, *args, **kwargs):
11861187
data = request.body
1187-
for event in json.loads(data):
1188+
events = json.loads(data)
1189+
1190+
for event in events:
11881191
try:
11891192
# Try to find a matching domain first
11901193
domain = Domain.objects.filter(email__iexact=event.get("email")).first()
@@ -1222,8 +1225,62 @@ def post(self, request, *args, **kwargs):
12221225
except (Domain.DoesNotExist, User.DoesNotExist, AttributeError, ValueError, json.JSONDecodeError) as e:
12231226
logger.error(f"Error processing SendGrid webhook event: {str(e)}")
12241227

1228+
# Send events to Slack webhook
1229+
self._send_to_slack(events)
1230+
12251231
return JsonResponse({"detail": "Inbound Sendgrid Webhook received"})
12261232

1233+
def _send_to_slack(self, events):
1234+
"""
1235+
Send SendGrid webhook events to Slack webhook.
1236+
1237+
Args:
1238+
events: List of SendGrid webhook event dictionaries
1239+
"""
1240+
try:
1241+
slack_webhook_url = os.environ.get("SLACK_WEBHOOK_URL")
1242+
1243+
if not slack_webhook_url:
1244+
logger.debug("SLACK_WEBHOOK_URL not configured, skipping Slack notification")
1245+
return
1246+
1247+
# Format events for Slack
1248+
for event in events:
1249+
event_type = event.get("event", "unknown")
1250+
email = event.get("email", "unknown")
1251+
timestamp = event.get("timestamp", "")
1252+
1253+
# Create a formatted message for this event
1254+
event_text = f"*📧 SendGrid Event: {event_type.upper()}*\n"
1255+
event_text += f"*Email:* {email}\n"
1256+
event_text += f"*Timestamp:* {timestamp}\n"
1257+
1258+
# Add additional details based on event type
1259+
if event_type == "bounce":
1260+
reason = event.get("reason", "Unknown")
1261+
event_text += f"*Reason:* {reason}\n"
1262+
elif event_type == "click":
1263+
url = event.get("url", "N/A")
1264+
event_text += f"*URL:* {url}\n"
1265+
1266+
# Add any other relevant fields
1267+
if "sg_message_id" in event:
1268+
event_text += f"*Message ID:* {event.get('sg_message_id')}\n"
1269+
1270+
# Prepare Slack payload
1271+
payload = {"blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": event_text}}]}
1272+
1273+
# Send to Slack
1274+
response = requests.post(slack_webhook_url, json=payload, timeout=5)
1275+
response.raise_for_status()
1276+
1277+
logger.info(f"Successfully sent {len(events)} SendGrid event(s) to Slack")
1278+
1279+
except requests.exceptions.RequestException as e:
1280+
logger.error(f"Failed to send SendGrid events to Slack: {str(e)}")
1281+
except Exception as e:
1282+
logger.error(f"Unexpected error sending to Slack: {str(e)}")
1283+
12271284

12281285
class CreateHunt(TemplateView):
12291286
model = Hunt

0 commit comments

Comments
 (0)