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
+147
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Box\Mod\Mailgateway;
|
||||
|
||||
use FOSSBilling\InformationException;
|
||||
use Webklex\PHPIMAP\IMAP;
|
||||
|
||||
/** IMAP transport; deliberately never uses Webklex's MOVE fallback (global EXPUNGE). */
|
||||
class ImapMailbox
|
||||
{
|
||||
private $client;
|
||||
private $inbox;
|
||||
private array $folders = [];
|
||||
|
||||
public function __construct(private array $settings, private ?\Closure $factory = null) {}
|
||||
|
||||
public function connect(): void
|
||||
{
|
||||
if ($this->settings['password'] === '') {
|
||||
throw new InformationException('Mailbox password is missing');
|
||||
}
|
||||
if ($this->factory === null) {
|
||||
if (!is_file(__DIR__ . '/vendor/autoload.php')) {
|
||||
throw new InformationException('Mail Gateway Composer dependencies are missing');
|
||||
}
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
}
|
||||
$config = [
|
||||
'host' => $this->settings['host'], 'port' => (int) $this->settings['port'],
|
||||
'protocol' => 'imap', 'encryption' => $this->settings['encryption'],
|
||||
'validate_cert' => true, 'username' => $this->settings['username'],
|
||||
'password' => $this->settings['password'], 'authentication' => null, 'timeout' => RuntimeConfig::get('imap_timeout_seconds'),
|
||||
];
|
||||
$this->client = $this->factory !== null ? ($this->factory)($config)
|
||||
: (new \Webklex\PHPIMAP\ClientManager(['options' => ['debug' => false]]))->make($config);
|
||||
$this->client->connect();
|
||||
$capabilities = $this->client->getConnection()->getCapabilities()->validatedData();
|
||||
if (!in_array('MOVE', array_map('strtoupper', $capabilities), true)) {
|
||||
throw new InformationException('IMAP server must support MOVE');
|
||||
}
|
||||
foreach (['inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder'] as $field) {
|
||||
$folder = $this->client->getFolderByPath($this->settings[$field]);
|
||||
if ($folder === null || $folder->no_select) {
|
||||
throw new InformationException('A configured IMAP folder is missing or cannot be selected');
|
||||
}
|
||||
$this->folders[$field] = $folder;
|
||||
}
|
||||
$this->inbox = $this->folders['inbox_folder'];
|
||||
}
|
||||
|
||||
public function uids(int $limit): array
|
||||
{
|
||||
$query = $this->inbox->query()->leaveUnread()->setSequence(IMAP::ST_UID)->whereUndeleted();
|
||||
$uids = array_map('intval', $query->search()->toArray());
|
||||
sort($uids, SORT_NUMERIC);
|
||||
return array_slice($uids, 0, $limit);
|
||||
}
|
||||
|
||||
public function read(int $uid): array
|
||||
{
|
||||
$message = $this->inbox->query()->leaveUnread()->fetchBody(false)->setFetchFlags(false)->getMessageByUid($uid);
|
||||
$header = $message->getHeader();
|
||||
$headers = self::rawHeaders($header->raw);
|
||||
$policy = new InboundPolicy();
|
||||
$ignored = $policy->ignoreReason($policy->headers(['headers' => $headers])) !== null;
|
||||
if (!$ignored) {
|
||||
if ((int) $message->getSize() > RuntimeConfig::get('max_message_bytes')) {
|
||||
throw new InformationException('Email exceeds the configured import size limit');
|
||||
}
|
||||
// Webklex parseBody() fetches RFC822.TEXT then restores Seen flags.
|
||||
// Fetch with true PEEK instead: even a crash cannot mark mail as read.
|
||||
$bodies = $this->client->getConnection()->fetch(['BODY.PEEK[TEXT]', 'UID'], [$uid], null, IMAP::ST_UID)->validatedData();
|
||||
$body = $bodies[$uid]['BODY[TEXT]'] ?? null;
|
||||
if (!is_string($body)) {
|
||||
throw new \RuntimeException('IMAP body was not returned');
|
||||
}
|
||||
$message->parseRawBody($body);
|
||||
}
|
||||
return self::data($message, $this->settings['mailbox'], $headers, $ignored);
|
||||
}
|
||||
|
||||
/** Keep repeated values and the literal null Return-Path lost by address parsers. */
|
||||
public static function rawHeaders(string $raw): array
|
||||
{
|
||||
$raw = preg_replace('/\r?\n[ \t]+/', ' ', $raw);
|
||||
$headers = [];
|
||||
foreach (preg_split('/\r?\n/', $raw) as $line) {
|
||||
if (preg_match('/^([A-Za-z0-9-]+):[ \t]*(.*)$/D', $line, $match)) {
|
||||
$headers[strtolower($match[1])][] = trim($match[2]);
|
||||
}
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
|
||||
public static function data(object $message, string $mailbox, array $headers, bool $ignored): array
|
||||
{
|
||||
$ids = $headers['message-id'] ?? [];
|
||||
if (count($ids) > 1) {
|
||||
throw new InformationException('Email has multiple Message-ID headers');
|
||||
}
|
||||
// A stable MIME fingerprint also deduplicates messages without a Message-ID.
|
||||
$id = $ids[0] ?? '<' . hash('sha256', $message->getHeader()->raw . ($ignored ? '' : $message->getRawBody())) . '@mailgateway.invalid>';
|
||||
$data = ['mailbox' => $mailbox, 'message_id' => $id, 'headers' => $headers];
|
||||
if ($ignored) {
|
||||
return $data;
|
||||
}
|
||||
$senders = $message->getFrom()->toArray();
|
||||
if (count($headers['from'] ?? []) !== 1 || count($senders) !== 1) {
|
||||
throw new InformationException('Email must have exactly one From address');
|
||||
}
|
||||
$sender = $senders[0];
|
||||
$content = trim($message->getTextBody());
|
||||
if ($content === '') {
|
||||
$html = preg_replace('/<(script|style)\b[^>]*>.*?<\/\1\s*>/is', '', $message->getHTMLBody());
|
||||
$html = preg_replace('/<br\s*\/?\s*>|<\/(?:p|div|li|tr|h[1-6])\s*>/i', "\n", $html);
|
||||
$content = trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
}
|
||||
$subject = trim(mb_decode_mimeheader((string) $message->getSubject()));
|
||||
return $data + [
|
||||
'sender_email' => $sender->mail,
|
||||
'sender_name' => trim(mb_decode_mimeheader($sender->personal)) ?: $sender->mail,
|
||||
'subject' => $subject === '' ? '(No subject)' : $subject,
|
||||
'content' => $content,
|
||||
];
|
||||
}
|
||||
|
||||
public function move(int $uid, string $destination): void
|
||||
{
|
||||
$this->client->openFolder($this->inbox->path);
|
||||
$protocol = $this->client->getConnection();
|
||||
// Native UID MOVE touches only this message. No COPY/STORE/EXPUNGE fallback.
|
||||
$response = $protocol->requestAndResponse('UID MOVE', [(string) $uid,
|
||||
$protocol->escapeString($this->folders[$destination]->path)], true);
|
||||
if (!$response->boolean()) {
|
||||
throw new \RuntimeException('IMAP MOVE failed');
|
||||
}
|
||||
}
|
||||
|
||||
public function disconnect(): void
|
||||
{
|
||||
if ($this->client !== null) {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user