mailbox($data); $messageId = $this->messageId($data['message_id'] ?? null); $policy = new InboundPolicy(); $headers = $policy->headers($data); $ignoreReason = $policy->ignoreReason($headers); $ticketId = $ignoreReason === null ? $this->subjectTicketId($data['subject'] ?? null) : null; // Hash only accepted inputs, independent of request field order. $payload = []; foreach (['sender_email', 'sender_name', 'subject', 'content'] as $field) { $value = $data[$field] ?? null; if ($value !== null && !is_string($value)) { throw new InformationException($field . ' must be a string'); } $payload[$field] = $value; } $payload['parents'] = []; // Preserve fingerprints of earlier new-ticket imports. if ($headers !== []) { $payload['headers'] = $headers; } $hash = hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)); $key = $this->key($mailbox, $messageId); $this->assertStandaloneTransaction(); $this->connection->beginTransaction(); try { $this->connection->insert('mailgateway_message', [ 'message_key' => $key, 'mailbox' => $mailbox, 'message_id' => $messageId, 'payload_hash' => $hash, ]); } catch (UniqueConstraintViolationException) { $this->connection->rollBack(); $row = $this->connection->fetchAssociative('SELECT * FROM mailgateway_message WHERE message_key = ?', [$key]); if (!$row || $row['payload_hash'] !== $hash || !$row['result_json']) { throw new InformationException('Message-ID is already registered with different contents'); } return array_merge(json_decode($row['result_json'], true, 512, JSON_THROW_ON_ERROR), ['duplicate' => true]); } catch (\Throwable $error) { $this->connection->rollBack(); throw $error; } try { if ($ignoreReason !== null) { $result = ['action' => 'ignored', 'reason' => $ignoreReason]; } elseif ($ticketId !== null) { $data['ticket_id'] = $ticketId; $result = $service->reply($data); $result['action'] = 'reply'; } else { $result = $service->import($data); $result['action'] = 'ticket'; } $result['duplicate'] = false; $this->connection->update('mailgateway_message', [ 'ticket_id' => $result['ticket_id'] ?? null, 'result_json' => json_encode($result, JSON_THROW_ON_ERROR), ], ['message_key' => $key]); $this->connection->commit(); return $result; } catch (\Throwable $error) { if ($this->connection->isTransactionActive()) { $this->connection->rollBack(); } // Drop entities whose writes were rolled back, including queued mail. $service->getDi()['em']->clear(); // Queue rejection notifications only after the import has rolled back. if ($error instanceof InformationException && in_array($error->getMessage(), [ 'Sender does not own this ticket', 'Sender does not uniquely match the ticket owner', ], true)) { $service->notifySenderMismatch($data['sender_email'], (int) $data['ticket_id']); } throw $error; } } private function subjectTicketId(mixed $subject): ?int { if (!is_string($subject) || trim($subject) === '' || strlen($subject) > 998 || !mb_check_encoding($subject, 'UTF-8') || preg_match('/[\x00-\x1F\x7F]/', $subject)) { throw new InformationException('A non-empty UTF-8 subject without control characters is required (maximum 998 bytes)'); } preg_match_all('/\[Ticket\b[^\]]*\]/i', $subject, $matches); if (preg_match('/\[Ticket\b/i', str_replace($matches[0], '', $subject))) { throw new InformationException('Incomplete ticket reference in subject'); } $ids = []; foreach ($matches[0] as $reference) { if (!preg_match('/^\[Ticket #([1-9][0-9]*)\]$/iD', $reference, $match) || filter_var($match[1], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) === false) { throw new InformationException('Invalid ticket reference in subject; expected [Ticket #123]'); } $ids[(int) $match[1]] = true; } if (count($ids) > 1) { throw new InformationException('Subject refers to multiple tickets'); } return $ids ? array_key_first($ids) : null; } private function assertStandaloneTransaction(): void { if ($this->connection->isTransactionActive()) { throw new \LogicException('Tracked import requires its own transaction'); } } private function mailbox(array $data): string { $mailbox = $data['mailbox'] ?? null; if (!is_string($mailbox) || strlen($mailbox) > 255 || !filter_var($mailbox, FILTER_VALIDATE_EMAIL)) { throw new InformationException('A valid mailbox address is required'); } return strtolower($mailbox); } private function messageId(mixed $value): string { if (!is_string($value) || strlen($value) > 998 || !preg_match('/^<[^<>\s@]+@[^<>\s@]+>$/D', $value) || preg_match('/[^\x21-\x7E]/', $value)) { throw new InformationException('Message-ID must be a single ASCII value'); } return $value; } private function key(string $mailbox, string $messageId): string { return hash('sha256', $mailbox . "\0" . $messageId); } }