HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/hi.naniguide.com/app/Http/Controllers/Webhook/ResendWebhookController.php
<?php

namespace App\Http\Controllers\Webhook;

use App\Http\Controllers\Controller;
use App\Models\CampaignLog;
use App\Models\CampaignRecipient;
use App\Models\DeliveryServer;
use App\Models\EmailProviderEvent;
use App\Models\ListSubscriber;
use App\Models\OutreachLead;
use App\Models\SuppressionList;
use App\Services\ComplaintService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class ResendWebhookController extends Controller
{
    public function __construct(
        protected ComplaintService $complaintService
    ) {}

    /**
     * Handle Resend webhook events.
     */
    public function handle(Request $request)
    {
        $payload = $request->all();
        $type = $payload['type'] ?? null;

        Log::info('Resend webhook received', [
            'ip' => $request->ip(),
            'type' => $type,
            'has_payload' => !empty($payload),
        ]);

        if (!is_string($type) || trim($type) === '') {
            return response()->json(['error' => 'Invalid payload'], 400);
        }

        $webhookSecret = $this->resolveWebhookSecret($payload);

        if ($webhookSecret !== null && !$this->validateSignature($request, $webhookSecret)) {
            Log::warning('Invalid Resend webhook signature', [
                'ip' => $request->ip(),
                'type' => $type,
            ]);
            return response()->json(['error' => 'Invalid signature'], 401);
        }

        $eventType = str_replace('email.', '', $type);

        $this->logProviderEvent('resend', $eventType, $payload);

        match ($eventType) {
            'opened' => $this->handleOpen($request, $payload),
            'clicked' => $this->handleClick($request, $payload),
            'bounced' => $this->handleBounce($request, $payload),
            'delivery_delayed' => $this->handleFailed($request, $payload),
            'complained' => $this->handleComplaint($payload),
            'delivered' => $this->handleDelivered($request, $payload),
            'sent' => null,
            default => Log::info('Ignored Resend webhook event', ['type' => $type]),
        };

        return response()->json(['status' => 'processed'], 200);
    }

    protected function handleOpen(Request $request, array $payload): void
    {
        $recipient = $this->resolveRecipientFromPayload($payload);
        if ($recipient) {
            $campaign = $recipient->campaign;
            if ($campaign && $campaign->track_opens && !$recipient->isOpened()) {
                $recipient->markAsOpened();
                $campaign->incrementOpenedCount();

                CampaignLog::logEvent(
                    $campaign->id,
                    'opened',
                    $recipient->id,
                    ['email' => $recipient->email, 'provider' => 'resend'],
                    $request->ip(),
                    data_get($payload, 'data.open.user_agent')
                );
            }
            return;
        }

        $lead = $this->resolveOutreachLeadFromPayload($payload);
        if ($lead && !in_array($lead->status, ['opened', 'clicked', 'replied'])) {
            $lead->update(['status' => 'opened', 'last_activity_at' => now()]);
            Log::info('Resend outreach open processed', ['lead_id' => $lead->id, 'email' => $lead->email]);
        }
    }

    protected function handleClick(Request $request, array $payload): void
    {
        $recipient = $this->resolveRecipientFromPayload($payload);
        if ($recipient) {
            $campaign = $recipient->campaign;
            $url = data_get($payload, 'data.click.link');

            if ($campaign && $campaign->track_clicks) {
                $wasOpened = $recipient->isOpened();
                $wasClicked = $recipient->isClicked();

                if ($campaign->track_opens && !$wasOpened) {
                    $recipient->markAsOpened();
                    $campaign->incrementOpenedCount();
                }

                if (!$wasClicked) {
                    $recipient->markAsClicked();
                    $campaign->incrementClickedCount();
                }

                CampaignLog::logEvent(
                    $campaign->id,
                    'clicked',
                    $recipient->id,
                    ['email' => $recipient->email, 'provider' => 'resend'],
                    $request->ip(),
                    data_get($payload, 'data.click.user_agent'),
                    is_string($url) ? $url : null
                );
            }
            return;
        }

        $lead = $this->resolveOutreachLeadFromPayload($payload);
        if ($lead && !in_array($lead->status, ['clicked', 'replied'])) {
            $lead->update(['status' => 'clicked', 'last_activity_at' => now()]);
            Log::info('Resend outreach click processed', ['lead_id' => $lead->id, 'email' => $lead->email]);
        }
    }

    protected function handleBounce(Request $request, array $payload): void
    {
        $recipient = $this->resolveRecipientFromPayload($payload);
        if ($recipient) {
            $campaign = $recipient->campaign;
            $email = (string) ($recipient->email ?? '');

            $bounceType = data_get($payload, 'data.bounce.type', 'hard');
            $isHardBounce = in_array(strtolower((string) $bounceType), ['hard', 'permanent', 'undetermined'], true);
            $reason = (string) (data_get($payload, 'data.bounce.message') ?? 'Resend reported bounce');

            $alreadyBounced = $recipient->status === 'bounced';
            if (!$alreadyBounced) {
                $recipient->markAsBounced();
                if ($campaign) {
                    $deliveryServerMeta = $this->resolveDeliveryServerMeta($recipient, $campaign->deliveryServer);
                    $campaign->incrementBouncedCount();

                    CampaignLog::logEvent(
                        $campaign->id,
                        'bounced',
                        $recipient->id,
                        [
                            'email' => $email,
                            'provider' => 'resend',
                            'bounce_type' => $bounceType,
                            'reason' => $reason,
                        ] + $deliveryServerMeta,
                        null,
                        null,
                        null,
                        $reason
                    );
                }
            }

            $subscriber = null;
            if ($email !== '') {
                $subscriber = ListSubscriber::query()
                    ->when($campaign?->list_id, fn ($q) => $q->where('list_id', $campaign->list_id))
                    ->where('email', strtolower(trim($email)))
                    ->first();
            }

            if ($subscriber && $isHardBounce) {
                $subscriber->update([
                    'status' => 'bounced',
                    'is_bounced' => true,
                    'bounced_at' => now(),
                    'suppressed_at' => now(),
                ]);

                SuppressionList::firstOrCreate(
                    [
                        'customer_id' => $subscriber->list?->customer_id,
                        'email' => $subscriber->email,
                    ],
                    [
                        'reason' => 'bounce',
                        'reason_description' => $reason,
                        'subscriber_id' => $subscriber->id,
                        'campaign_id' => $campaign?->id,
                        'suppressed_at' => now(),
                    ]
                );
            }

            Log::info('Resend bounce processed', [
                'email' => $email,
                'bounce_type' => $bounceType,
                'reason' => $reason,
                'campaign_id' => $campaign?->id,
                'recipient_id' => $recipient->id,
            ]);
            return;
        }

        $lead = $this->resolveOutreachLeadFromPayload($payload);
        if ($lead && $lead->status !== 'bounced') {
            $lead->update(['status' => 'bounced', 'last_activity_at' => now()]);
            Log::info('Resend outreach bounce processed', ['lead_id' => $lead->id, 'email' => $lead->email]);
        }
    }

    protected function handleFailed(Request $request, array $payload): void
    {
        $recipient = $this->resolveRecipientFromPayload($payload);
        if ($recipient) {
            $campaign = $recipient->campaign;
            $email = (string) ($recipient->email ?? '');
            $reason = (string) (data_get($payload, 'data.delay.message') ?? 'Resend reported delivery delay/failure');

            $alreadyFailed = $recipient->status === 'failed';
            if (!$alreadyFailed) {
                $recipient->markAsFailed($reason);
                if ($campaign) {
                    $deliveryServerMeta = $this->resolveDeliveryServerMeta($recipient, $campaign->deliveryServer);
                    $campaign->incrementFailedCount();

                    CampaignLog::logEvent(
                        $campaign->id,
                        'failed',
                        $recipient->id,
                        [
                            'email' => $email,
                            'provider' => 'resend',
                            'reason' => $reason,
                        ] + $deliveryServerMeta,
                        null,
                        null,
                        null,
                        $reason
                    );
                }
            }
            return;
        }

        $lead = $this->resolveOutreachLeadFromPayload($payload);
        if ($lead && !in_array($lead->status, ['bounced', 'failed'])) {
            $lead->update(['status' => 'failed', 'last_activity_at' => now()]);
            Log::info('Resend outreach failed processed', ['lead_id' => $lead->id, 'email' => $lead->email]);
        }
    }

    protected function handleComplaint(array $payload): void
    {
        $email = $this->extractEmailFromPayload($payload);
        if (!$email) {
            return;
        }

        $emailId = data_get($payload, 'data.email_id');

        try {
            $this->complaintService->processComplaint(
                email: $email,
                provider: 'resend',
                providerMessageId: is_string($emailId) ? $emailId : null,
                feedbackId: null,
                rawData: json_encode($payload),
                meta: $payload
            );
        } catch (\Exception $e) {
            Log::error('Error processing Resend complaint: ' . $e->getMessage(), [
                'payload' => $payload,
            ]);
        }

        $lead = $this->resolveOutreachLeadFromPayload($payload);
        if ($lead && $lead->status !== 'unsubscribed') {
            $lead->update(['status' => 'unsubscribed', 'last_activity_at' => now()]);
            Log::info('Resend outreach complaint processed', ['lead_id' => $lead->id, 'email' => $lead->email]);
        }
    }

    protected function handleDelivered(Request $request, array $payload): void
    {
        $recipient = $this->resolveRecipientFromPayload($payload);
        if ($recipient) {
            $campaign = $recipient->campaign;
            if (!$campaign) {
                return;
            }

            $meta = is_array($recipient->meta) ? $recipient->meta : [];
            $alreadyDelivered = (bool) ($meta['resend_delivered'] ?? false);
            if ($alreadyDelivered) {
                return;
            }

            $meta['resend_delivered'] = true;
            $meta['resend_delivered_at'] = data_get($payload, 'data.created_at') ?: now()->toISOString();
            $meta['resend_email_id'] = data_get($payload, 'data.email_id');

            $recipient->update(['meta' => $meta]);

            if ((int) $campaign->delivered_count < (int) $campaign->sent_count) {
                $campaign->increment('delivered_count');
            }

            $deliveryServerMeta = $this->resolveDeliveryServerMeta($recipient, $campaign->deliveryServer);

            CampaignLog::logEvent(
                $campaign->id,
                'delivered',
                $recipient->id,
                [
                    'email' => $recipient->email,
                    'provider' => 'resend',
                    'email_id' => data_get($payload, 'data.email_id'),
                ] + $deliveryServerMeta,
                $request->ip(),
                $request->userAgent()
            );
            return;
        }

        $lead = $this->resolveOutreachLeadFromPayload($payload);
        if ($lead && in_array($lead->status, ['pending', 'sent'])) {
            $meta = is_array($lead->meta) ? $lead->meta : [];
            $meta['resend_delivered'] = true;
            $meta['resend_delivered_at'] = data_get($payload, 'data.created_at') ?: now()->toISOString();
            $lead->update(['meta' => $meta, 'last_activity_at' => now()]);
            Log::info('Resend outreach delivered processed', ['lead_id' => $lead->id, 'email' => $lead->email]);
        }
    }

    protected function resolveRecipientFromPayload(array $payload): ?CampaignRecipient
    {
        $email = $this->extractEmailFromPayload($payload);
        if (!$email) {
            return null;
        }

        // Try to find by email_id stored in recipient meta
        $emailId = data_get($payload, 'data.email_id');
        if (is_string($emailId) && trim($emailId) !== '') {
            $found = CampaignRecipient::whereJsonContains('meta->resend_email_id', $emailId)
                ->orWhereJsonContains('meta->resend_message_id', $emailId)
                ->first();
            if ($found) {
                return $found;
            }
        }

        // Fallback: match by email address (most recent sent/pending recipient)
        return CampaignRecipient::where('email', trim($email))
            ->whereIn('status', ['sent', 'pending', 'delivered', 'failed', 'bounced'])
            ->latest('id')
            ->first();
    }

    protected function extractEmailFromPayload(array $payload): ?string
    {
        $to = data_get($payload, 'data.to');
        if (is_array($to) && isset($to[0]) && is_string($to[0]) && trim($to[0]) !== '') {
            return trim($to[0]);
        }

        $to = data_get($payload, 'to');
        if (is_array($to) && isset($to[0]) && is_string($to[0]) && trim($to[0]) !== '') {
            return trim($to[0]);
        }

        return null;
    }

    protected function resolveOutreachLeadFromPayload(array $payload): ?OutreachLead
    {
        $email = $this->extractEmailFromPayload($payload);
        if (!$email) {
            return null;
        }

        $from = data_get($payload, 'data.from');
        if (!is_string($from) || trim($from) === '') {
            return null;
        }

        $domain = null;
        if (str_contains($from, '@')) {
            $domain = substr($from, strrpos($from, '@') + 1);
        }

        // Find Resend delivery servers matching the from email so we can
        // scope the lead search to the right customer.
        $serverQuery = DeliveryServer::query()
            ->where('type', 'resend')
            ->where(function ($q) use ($from, $domain) {
                $q->where('from_email', $from);
                if ($domain) {
                    $q->orWhereJsonContains('settings->sending_domain', $domain);
                }
            });

        $customerIds = $serverQuery->pluck('customer_id')->filter()->unique()->values();

        if ($customerIds->isEmpty()) {
            return null;
        }

        return OutreachLead::query()
            ->whereHas('campaign', function ($q) use ($customerIds) {
                $q->whereIn('customer_id', $customerIds);
            })
            ->where('email', trim($email))
            ->whereIn('status', ['sent', 'pending', 'opened', 'clicked'])
            ->latest('id')
            ->first();
    }

    protected function resolveDeliveryServerMeta(CampaignRecipient $recipient, ?DeliveryServer $fallbackServer = null): array
    {
        $meta = is_array($recipient->meta) ? $recipient->meta : [];

        $deliveryServerId = (int) ($meta['delivery_server_id'] ?? 0);
        $deliveryServerName = trim((string) ($meta['delivery_server_name'] ?? ''));
        $deliveryServerFromEmail = trim((string) ($meta['delivery_server_from_email'] ?? ''));

        if ($deliveryServerId <= 0 && $fallbackServer) {
            $deliveryServerId = (int) $fallbackServer->id;
            if ($deliveryServerName === '') {
                $deliveryServerName = (string) $fallbackServer->name;
            }
            if ($deliveryServerFromEmail === '') {
                $deliveryServerFromEmail = (string) $fallbackServer->from_email;
            }
        }

        return array_filter([
            'delivery_server_id' => $deliveryServerId > 0 ? $deliveryServerId : null,
            'delivery_server_name' => $deliveryServerName !== '' ? $deliveryServerName : null,
            'delivery_server_from_email' => $deliveryServerFromEmail !== '' ? $deliveryServerFromEmail : null,
        ], static fn ($value) => $value !== null);
    }

    /**
     * Resolve webhook secret from delivery server settings.
     */
    protected function resolveWebhookSecret(array $payload): ?string
    {
        $from = data_get($payload, 'data.from');
        if (!is_string($from) || trim($from) === '') {
            return null;
        }

        $email = $this->extractEmailFromPayload($payload);
        if (!$email) {
            return null;
        }

        // Try to find a resend delivery server matching the from domain
        $domain = null;
        if (str_contains($from, '@')) {
            $domain = substr($from, strrpos($from, '@') + 1);
        }

        $query = DeliveryServer::query()->where('type', 'resend');

        if ($domain) {
            $query->where(function ($q) use ($domain, $from) {
                $q->where('from_email', $from)
                    ->orWhereJsonContains('settings->sending_domain', $domain);
            });
        }

        $server = $query->first();

        if (!$server) {
            return null;
        }

        $settings = $server->settings ?? [];
        $secret = $settings['webhook_secret'] ?? null;

        return is_string($secret) && trim($secret) !== '' ? trim($secret) : null;
    }

    /**
     * Validate Svix webhook signature.
     */
    protected function validateSignature(Request $request, string $secret): bool
    {
        $svixId = $request->header('svix-id');
        $svixTimestamp = $request->header('svix-timestamp');
        $svixSignature = $request->header('svix-signature');

        if (!is_string($svixId) || !is_string($svixTimestamp) || !is_string($svixSignature)) {
            return false;
        }

        $payload = (string) $request->getContent();
        $signedContent = $svixId . '.' . $svixTimestamp . '.' . $payload;

        $key = $this->resolveSigningKey($secret);
        if ($key === null) {
            return false;
        }

        $computed = base64_encode(hash_hmac('sha256', $signedContent, $key, true));

        // svix-signature can contain multiple v1,signature pairs
        $parts = explode(',', $svixSignature);
        foreach ($parts as $part) {
            $part = trim($part);
            if (str_starts_with($part, 'v1,')) {
                $signature = substr($part, 3);
                if (hash_equals($computed, $signature)) {
                    return true;
                }
            }
        }

        return false;
    }

    protected function resolveSigningKey(string $secret): ?string
    {
        // Svix secrets may be base64-encoded or prefixed with whsec_
        if (str_starts_with($secret, 'whsec_')) {
            $decoded = base64_decode(substr($secret, 6), true);
            if ($decoded !== false) {
                return $decoded;
            }
            return substr($secret, 6);
        }

        $decoded = base64_decode($secret, true);
        if ($decoded !== false && $decoded !== '' && $decoded !== $secret) {
            return $decoded;
        }

        return $secret;
    }

    /**
     * Persist the webhook event to EmailProviderEvent for log viewing.
     */
    protected function logProviderEvent(string $provider, string $eventType, array $payload): void
    {
        try {
            $email = data_get($payload, 'data.to.0.email')
                ?? data_get($payload, 'data.email')
                ?? data_get($payload, 'email')
                ?? null;

            $occurredAt = data_get($payload, 'created_at')
                ?? data_get($payload, 'data.created_at')
                ?? now()->toIso8601String();

            $recipient = $this->resolveRecipientFromPayload($payload);

            EmailProviderEvent::create([
                'provider' => $provider,
                'event_type' => $eventType,
                'campaign_id' => $recipient?->campaign_id,
                'recipient_id' => $recipient?->id,
                'subscriber_id' => $recipient?->subscriber_id,
                'email' => $email,
                'occurred_at' => $occurredAt,
                'payload' => $payload,
            ]);
        } catch (\Exception $e) {
            Log::warning('Failed to log provider event', [
                'provider' => $provider,
                'event_type' => $eventType,
                'error' => $e->getMessage(),
            ]);
        }
    }
}