Files
fossbilling-mailgateway/ImapMailbox.php
T

148 lines
6.5 KiB
PHP

<?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();
}
}
}