65536) throw new RuntimeException('Invalid input'); $normalized = []; foreach ($headers as $name => $value) { $key = strtolower($name); if (!str_starts_with($key, 'webhook-')) continue; if (array_key_exists($key, $normalized) || (is_array($value) && count($value) !== 1)) throw new RuntimeException('Duplicate header'); $value = is_array($value) ? array_values($value)[0] : $value; if (!is_string($value)) throw new RuntimeException('Invalid header'); $normalized[$key] = $value; } $id = $normalized['webhook-id'] ?? ''; $timestamp = $normalized['webhook-timestamp'] ?? ''; $signature = $normalized['webhook-signature'] ?? ''; if (!preg_match('/^evt_[a-f0-9]{24}$/D', $id) || !preg_match('/^[0-9]{1,13}$/D', $timestamp) || !preg_match('/^v1,[a-f0-9]{64}$/D', $signature)) throw new RuntimeException('Invalid signed headers'); if ($toleranceSeconds < 0 || abs(($now ?? time()) - (int) $timestamp) > $toleranceSeconds) throw new RuntimeException('Stale timestamp'); // Exact bytes BEFORE JSON parsing. Use the literal secret as UTF-8 text. $expected = hash_hmac('sha256', $id . '.' . $timestamp . '.' . $rawBody, $secret); if (!hash_equals($expected, substr($signature, 3))) throw new RuntimeException('Invalid signature'); $event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); if (!is_array($event) || ($event['id'] ?? null) !== $id || !is_string($event['createdAt'] ?? null)) throw new RuntimeException('Invalid event'); $data = $event['data'] ?? null; $statuses = ['accepted', 'delayed', 'delivered', 'bounced', 'complained', 'rejected', 'failed', 'ambiguous']; if (!is_array($data) || !is_string($data['emailId'] ?? null) || !preg_match('/^eml_[a-f0-9]{24}$/D', $data['emailId']) || !in_array($data['status'] ?? null, $statuses, true) || ($event['type'] ?? null) !== 'email.' . $data['status'] || !array_key_exists('detail', $data) || !($data['detail'] === null || is_string($data['detail']))) throw new RuntimeException('Invalid event data'); return $event; } function respond(int $status, ?string $error = null): never { http_response_code($status); header('Content-Type: application/json'); header('Cache-Control: no-store'); if ($status === 405) header('Allow: POST'); echo json_encode($error === null ? ['ok' => true] : ['ok' => false, 'error' => $error], JSON_THROW_ON_ERROR); exit; } // Safe to require this file from a local verification test without serving. if (isset($_SERVER['SCRIPT_FILENAME']) && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) { if (($_SERVER['REQUEST_URI'] ?? '') !== '/mailcore-webhook') respond(404, 'not_found'); if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') respond(405, 'method_not_allowed'); if (strtolower(trim(explode(';', $_SERVER['CONTENT_TYPE'] ?? '')[0])) !== 'application/json') respond(415, 'content_type_required'); // Buffer/dechunk and reject duplicate signed headers at the reverse proxy; // PHP SAPI can overwrite duplicate headers before the application sees them. $rawBody = file_get_contents('php://input', false, null, 0, 65537); if ($rawBody === false) respond(400, 'request_invalid'); if (strlen($rawBody) > 65536) respond(413, 'body_too_large'); $secret = getenv('MAILCORE_WEBHOOK_SECRET') ?: ''; if ($secret === '') respond(500, 'receiver_not_configured'); try { $event = verifyWebhook($rawBody, [ 'Webhook-Id' => $_SERVER['HTTP_WEBHOOK_ID'] ?? '', 'Webhook-Timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '', 'Webhook-Signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '', ], $secret); } catch (Throwable $error) { respond(401, 'webhook_invalid'); } try { $inbox = new SQLite3(getenv('MAILCORE_WEBHOOK_INBOX') ?: 'mailcore-webhook-inbox.sqlite3'); $inbox->enableExceptions(true); $inbox->busyTimeout(5000); $inbox->exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, raw_body BLOB NOT NULL, received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);'); $insert = $inbox->prepare('INSERT OR IGNORE INTO inbox (id, raw_body) VALUES (:id, :body)'); $insert->bindValue(':id', $event['id'], SQLITE3_TEXT); $insert->bindValue(':body', $rawBody, SQLITE3_BLOB); $insert->execute(); // Autocommit completes before acknowledging. $query = $inbox->prepare('SELECT raw_body FROM inbox WHERE id = :id'); $query->bindValue(':id', $event['id'], SQLITE3_TEXT); if ($query->execute()->fetchArray(SQLITE3_ASSOC)['raw_body'] !== $rawBody) respond(409, 'event_id_conflict'); $inbox->close(); respond(200); // Already persisted duplicates also acknowledge safely. } catch (Throwable $error) { respond(500, 'inbox_unavailable'); } }