|
1 | 1 | import json |
2 | 2 | from datetime import datetime |
| 3 | +from urllib.parse import urljoin |
| 4 | + |
| 5 | +import requests |
3 | 6 |
|
4 | 7 | from ..exceptions import ( |
| 8 | + AnymailConfigurationError, |
5 | 9 | AnymailImproperlyInstalled, |
6 | 10 | AnymailInvalidAddress, |
7 | 11 | AnymailWebhookValidationFailure, |
8 | 12 | _LazyError, |
9 | 13 | ) |
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 | +) |
11 | 23 | from ..utils import get_anymail_setting, parse_single_address |
12 | 24 | from .base import AnymailBaseWebhookView, AnymailCoreWebhookView |
13 | 25 |
|
@@ -193,3 +205,132 @@ def esp_to_anymail_event(self, esp_event, request): |
193 | 205 | user_agent=user_agent, |
194 | 206 | esp_event=esp_event, |
195 | 207 | ) |
| 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