verifySignature($request)) { Log::warning('Invalid Mailgun webhook signature'); return response('Unauthorized', 401); } $eventData = $request->input('event-data', []); $event = $eventData['event'] ?? null; Log::info('Mailgun webhook received', ['event' => $event, 'data' => $eventData]); switch ($event) { case 'bounced': $this->handleBounce($eventData); break; case 'complained': $this->handleComplaint($eventData); break; case 'unsubscribed': $this->handleUnsubscribe($eventData); break; default: Log::info('Unhandled Mailgun event', ['event' => $event]); } return response('OK', 200); } /** * Handle bounce events */ protected function handleBounce(array $eventData) { $email = $eventData['recipient'] ?? null; $errorCode = $eventData['delivery-status']['code'] ?? null; $errorMessage = $eventData['delivery-status']['description'] ?? 'Unknown bounce'; if (!$email) { Log::warning('Bounce event missing recipient email', $eventData); return; } // Determine bounce type based on error code $bounceType = $this->determineBounceTypeFromCode($errorCode); // Extract mailing ID from message tags or headers if available $mailingId = $this->extractMailingId($eventData); EmailBounce::recordBounce($email, $bounceType, $errorMessage, $mailingId); Log::info("Recorded {$bounceType} bounce", [ 'email' => $email, 'code' => $errorCode, 'message' => $errorMessage, 'mailing_id' => $mailingId, ]); } /** * Handle complaint (spam) events */ protected function handleComplaint(array $eventData) { $email = $eventData['recipient'] ?? null; if (!$email) { Log::warning('Complaint event missing recipient email', $eventData); return; } $mailingId = $this->extractMailingId($eventData); EmailBounce::recordBounce($email, 'complaint', 'Spam complaint', $mailingId); Log::warning("Recorded spam complaint", [ 'email' => $email, 'mailing_id' => $mailingId, ]); } /** * Handle unsubscribe events */ protected function handleUnsubscribe(array $eventData) { $email = $eventData['recipient'] ?? null; if (!$email) { Log::warning('Unsubscribe event missing recipient email', $eventData); return; } // You might want to handle unsubscribes differently // For now, we'll just log it Log::info("Unsubscribe event", ['email' => $email]); // Optionally suppress the email // EmailBounce::suppressEmail($email, 'Unsubscribed via Mailgun'); } /** * Determine bounce type from Mailgun error code */ protected function determineBounceTypeFromCode($code): string { if (!$code) return 'unknown'; // Mailgun error codes for hard bounces $hardBounceCodes = [550, 551, 553, 554]; // Mailgun error codes for soft bounces $softBounceCodes = [421, 450, 451, 452, 552]; if (in_array($code, $hardBounceCodes)) { return 'hard'; } if (in_array($code, $softBounceCodes)) { return 'soft'; } return 'unknown'; } /** * Extract mailing ID from event data */ protected function extractMailingId(array $eventData): ?int { // Check if mailing ID is in message tags $tags = $eventData['message']['headers']['message-id'] ?? ''; if (preg_match('/mailing-(\d+)/', $tags, $matches)) { return (int) $matches[1]; } // Check custom headers $headers = $eventData['message']['headers'] ?? []; if (isset($headers['X-Mailing-ID'])) { return (int) $headers['X-Mailing-ID']; } return null; } /** * Verify Mailgun webhook signature */ protected function verifySignature(Request $request): bool { $timestamp = $request->input('timestamp'); $token = $request->input('token'); $signature = $request->input('signature'); $signingKey = config('services.mailgun.webhook_key'); if (!$signingKey) { Log::warning('Mailgun webhook signing key not configured'); return config('app.env') !== 'production'; // Skip verification in non-production } $expectedSignature = hash_hmac('sha256', $timestamp . $token, $signingKey); return hash_equals($expectedSignature, $signature); } }