"""Resend webhook handler — delivery event tracking.
Receives webhook events from Resend (delivered, bounced, opened, clicked,
complained, deferred) and updates campaign recipient statuses accordingly.
Webhook signature is verified via Resend's HMAC-SHA256 header.
"""
import hashlib
import hmac
import json
import logging
import os
from datetime import datetime, timezone
from flask import Blueprint, jsonify, request
logger = logging.getLogger("agentforms.webhooks.resend")
logger.setLevel(logging.INFO)
if not logger.handlers:
_h = logging.StreamHandler(__import__("sys").stderr)
_h.setFormatter(logging.Formatter("[resend-webhook] %(message)s"))
logger.addHandler(_h)
resend_bp = Blueprint("resend_webhook", __name__)
# Map Resend event types to our delivery_status values
_EVENT_MAP = {
"delivered": "delivered",
"bounced": "bounced",
"opened": "opened",
"clicked": "clicked",
"complained": "complained",
"deferred": "deferred",
"sent": "sent", # Already tracked at send time, but good to confirm
"processing": "pending",
}
# Events that count as "delivered" for analytics
_DELIVERY_EVENTS = {"delivered", "opened", "clicked"}
# Events that count as "failed" for analytics
_FAILURE_EVENTS = {"bounced", "complained"}
def _verify_signature(payload: bytes, signature: str, secret: str) -> bool:
"""Verify Resend webhook HMAC-SHA256 signature."""
if not secret:
logger.warning("RESEND_WEBHOOK_SECRET not set — skipping signature verification")
return True
expected = hmac.new(
secret.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
def _handle_event(event_data: dict):
"""Process a single Resend webhook event and update recipient status."""
from app.models import get_recipient_by_message_id, update_recipient_status
message_id = event_data.get("message_id") or event_data.get("id")
if not message_id:
logger.warning("Webhook event missing message_id: %s", event_data.get("type"))
return
event_type = event_data.get("type", "")
delivery_status = _EVENT_MAP.get(event_type)
if not delivery_status:
logger.warning("Unknown Resend event type: %s", event_type)
return
# Find the recipient by message_id
recipient = get_recipient_by_message_id(message_id)
if not recipient:
logger.warning("No recipient found for message_id %s", message_id)
return
# Update recipient status
update_recipient_status(
recipient["campaign_id"],
recipient["id"],
delivery_status,
error=event_data.get("error", {}).get("message") if event_type == "bounced" else None,
)
logger.info(
"Updated recipient %s for campaign %s: %s (event: %s)",
recipient["id"],
recipient["campaign_id"],
delivery_status,
event_type,
)
@resend_bp.route("/resend/webhook", methods=["POST"])
def resend_webhook():
"""Handle incoming Resend webhooks.
Resend sends POST requests with JSON body containing event data.
Signature is verified via X-Resend-Signature header.
"""
# Get signature
signature = request.headers.get("X-Resend-Signature", "")
payload = request.get_data()
# Verify signature
webhook_secret = os.environ.get("RESEND_WEBHOOK_SECRET", "")
if not _verify_signature(payload, signature, webhook_secret):
logger.warning("Invalid webhook signature from Resend")
return jsonify({"error": "invalid signature"}), 401
try:
data = request.get_json(force=True)
except Exception as e:
logger.warning("Failed to parse webhook payload: %s", e)
return jsonify({"error": "invalid payload"}), 400
# Handle single event or batch
events = data if isinstance(data, list) else [data]
results = {"processed": 0, "errors": []}
for event in events:
try:
_handle_event(event)
results["processed"] += 1
except Exception as e:
logger.error("Error processing Resend event: %s", e)
results["errors"].append(str(e))
return jsonify(
{
"status": "ok",
"processed": results["processed"],
"errors": results["errors"],
}
), 200