Skip to content

Commit f518662

Browse files
committed
Add Resend inbound handling.
1 parent e0f50c2 commit f518662

3 files changed

Lines changed: 530 additions & 2 deletions

File tree

anymail/urls.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from .webhooks.mandrill import MandrillCombinedWebhookView
1616
from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
1717
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
18-
from .webhooks.resend import ResendTrackingWebhookView
18+
from .webhooks.resend import ResendInboundWebhookView, ResendTrackingWebhookView
1919
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
2020
from .webhooks.sendinblue import (
2121
SendinBlueInboundWebhookView,
@@ -124,6 +124,11 @@
124124
PostmarkTrackingWebhookView.as_view(),
125125
name="postmark_tracking_webhook",
126126
),
127+
path(
128+
"resend/inbound/",
129+
ResendInboundWebhookView.as_view(),
130+
name="resend_inbound_webhook",
131+
),
127132
path(
128133
"resend/tracking/",
129134
ResendTrackingWebhookView.as_view(),

anymail/webhooks/resend.py

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
import json
22
from datetime import datetime
3+
from urllib.parse import urljoin
4+
5+
import requests
36

47
from ..exceptions import (
8+
AnymailConfigurationError,
59
AnymailImproperlyInstalled,
610
AnymailInvalidAddress,
711
AnymailWebhookValidationFailure,
812
_LazyError,
913
)
10-
from ..signals import AnymailTrackingEvent, EventType, RejectReason, tracking
14+
from ..inbound import AnymailInboundMessage
15+
from ..signals import (
16+
AnymailInboundEvent,
17+
AnymailTrackingEvent,
18+
EventType,
19+
RejectReason,
20+
inbound,
21+
tracking,
22+
)
1123
from ..utils import get_anymail_setting, parse_single_address
1224
from .base import AnymailBaseWebhookView, AnymailCoreWebhookView
1325

@@ -193,3 +205,132 @@ def esp_to_anymail_event(self, esp_event, request):
193205
user_agent=user_agent,
194206
esp_event=esp_event,
195207
)
208+
209+
210+
class ResendInboundWebhookView(SvixWebhookValidationMixin, AnymailBaseWebhookView):
211+
"""Handler for Resend.com inbound email webhooks"""
212+
213+
# https://resend.com/docs/webhooks/emails/received
214+
215+
esp_name = "Resend"
216+
signal = inbound
217+
218+
def __init__(self, **kwargs):
219+
super().__init__(**kwargs)
220+
self.api_key = get_anymail_setting(
221+
"api_key",
222+
esp_name=self.esp_name,
223+
kwargs=kwargs,
224+
allow_bare=True,
225+
)
226+
self.api_url = get_anymail_setting(
227+
"api_url",
228+
esp_name=self.esp_name,
229+
kwargs=kwargs,
230+
default="https://api.resend.com/",
231+
)
232+
if not self.api_url.endswith("/"):
233+
self.api_url += "/"
234+
235+
def parse_events(self, request):
236+
esp_event = json.loads(request.body.decode("utf-8"))
237+
if esp_event.get("type") != "email.received":
238+
raise AnymailConfigurationError(
239+
f"You seem to have set Resend's"
240+
f" *{esp_event.get('type', 'tracking')}* webhook"
241+
f" to Anymail's Resend *inbound* webhook URL."
242+
)
243+
return [self.esp_to_anymail_event(esp_event, request)]
244+
245+
def esp_to_anymail_event(self, esp_event, request):
246+
try:
247+
event_id = request.headers["svix-id"]
248+
except KeyError:
249+
event_id = None
250+
251+
try:
252+
timestamp = datetime.fromisoformat(
253+
esp_event["created_at"].replace("Z", "+00:00")
254+
)
255+
except (KeyError, ValueError):
256+
timestamp = None
257+
258+
email_id = esp_event.get("data", {}).get("email_id")
259+
message = self._fetch_inbound_email(email_id)
260+
261+
return AnymailInboundEvent(
262+
event_type=EventType.INBOUND,
263+
timestamp=timestamp,
264+
event_id=event_id,
265+
esp_event=esp_event,
266+
message=message,
267+
)
268+
269+
def _fetch_inbound_email(self, email_id):
270+
"""Fetch full email content from Resend API and return AnymailInboundMessage."""
271+
url = urljoin(self.api_url, f"emails/receiving/{email_id}")
272+
response = requests.get(
273+
url, headers={"Authorization": f"Bearer {self.api_key}"}
274+
)
275+
response.raise_for_status()
276+
data = response.json()
277+
278+
# Prefer raw MIME when available (more complete representation)
279+
raw = data.get("raw") or {}
280+
raw_url = raw.get("download_url")
281+
if raw_url:
282+
raw_response = requests.get(raw_url)
283+
raw_response.raise_for_status()
284+
return AnymailInboundMessage.parse_raw_mime_bytes(raw_response.content)
285+
286+
# Fall back to constructing from parsed fields
287+
headers = []
288+
esp_headers = data.get("headers") or {}
289+
if isinstance(esp_headers, dict):
290+
for name, value in esp_headers.items():
291+
if isinstance(value, list):
292+
for v in value:
293+
headers.append((name, v))
294+
else:
295+
headers.append((name, value))
296+
elif isinstance(esp_headers, list):
297+
# Handle list-of-dicts format (e.g., [{"name": ..., "value": ...}])
298+
headers = [(h["name"], h["value"]) for h in esp_headers]
299+
300+
attachments = [
301+
self._fetch_attachment(att) for att in data.get("attachments") or []
302+
]
303+
304+
message = AnymailInboundMessage.construct(
305+
from_email=data.get("from"),
306+
to=", ".join(data.get("to") or []) or None,
307+
cc=", ".join(data.get("cc") or []) or None,
308+
bcc=", ".join(data.get("bcc") or []) or None,
309+
subject=data.get("subject"),
310+
headers=headers,
311+
text=data.get("text"),
312+
html=data.get("html"),
313+
attachments=attachments,
314+
)
315+
316+
if data.get("reply_to") and "Reply-To" not in message:
317+
message["Reply-To"] = ", ".join(data["reply_to"])
318+
if data.get("message_id") and "Message-ID" not in message:
319+
message["Message-ID"] = data["message_id"]
320+
321+
return message
322+
323+
def _fetch_attachment(self, attachment):
324+
"""Download attachment content and return as AnymailInboundMessage attachment."""
325+
url = attachment["download_url"]
326+
response = requests.get(url)
327+
response.raise_for_status()
328+
content_type = response.headers.get("Content-Type") or attachment.get(
329+
"content_type", "application/octet-stream"
330+
)
331+
return AnymailInboundMessage.construct_attachment(
332+
content_type=content_type,
333+
content=response.content,
334+
filename=attachment.get("filename"),
335+
content_id=attachment.get("content_id"),
336+
)

0 commit comments

Comments
 (0)