Files
fossbilling-mailgateway/MessageTracker.php
T

147 lines
6.2 KiB
PHP

<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use FOSSBilling\InformationException;
class MessageTracker
{
public function __construct(private Connection $connection) {}
public function process(array $data, Service $service): array
{
$mailbox = $this->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 <id@domain> value');
}
return $value;
}
private function key(string $mailbox, string $messageId): string
{
return hash('sha256', $mailbox . "\0" . $messageId);
}
}