Implement IMAP email-to-ticket gateway
This commit is contained in:
1 parent
b7e5884457
commit
6f25869345
33 files changed
+5619
-5
No files matched your search
@@ -0,0 +1,146 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user