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('/|<\/(?: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(); } } }