Files
hyprcrd/public/plugins/email/ImapClient.php
2026-04-10 10:42:18 +02:00

473 lines
17 KiB
PHP

<?php
/**
* Minimal pure-PHP IMAP4rev1 client.
*
* Supports:
* - SSL/TLS connections (imaps://, port 993)
* - LOGIN, SELECT, UID SEARCH, UID FETCH, LOGOUT
* - MIME message body extraction (text/plain preferred, HTML fallback)
* - Quoted-printable + base64 decoding
* - MIME-encoded header decoding (=?utf-8?B?...?=)
*
* NOT supported (deferred for MVP): STARTTLS, IDLE, threads, attachments
* download, server-side search, multiple folders.
*
* Errors throw RuntimeException with a friendly message.
*/
class ImapClient
{
private $sock = null;
private int $tag = 0;
private string $host;
private int $port;
private string $encryption; // 'ssl' | 'tls' | 'none'
private int $timeout;
public function __construct(string $host, int $port = 993, string $encryption = 'ssl', int $timeout = 15)
{
$this->host = $host;
$this->port = $port;
$this->encryption = $encryption;
$this->timeout = $timeout;
}
public function connect(): void
{
$scheme = $this->encryption === 'ssl' ? 'ssl' : 'tcp';
$url = $scheme . '://' . $this->host . ':' . $this->port;
$ctx = stream_context_create([
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
'allow_self_signed' => false,
],
]);
$errno = 0; $errstr = '';
$this->sock = @stream_socket_client($url, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT, $ctx);
if (!$this->sock) {
throw new RuntimeException("Connect failed: $errstr ($errno)");
}
stream_set_timeout($this->sock, $this->timeout);
// Read greeting
$greet = $this->readLine();
if (strpos($greet, '* OK') !== 0 && strpos($greet, '* PREAUTH') !== 0) {
throw new RuntimeException('Bad server greeting: ' . trim($greet));
}
}
public function login(string $user, string $pass): void
{
// Quote string literals to be safe
$user = $this->quoteString($user);
$pass = $this->quoteString($pass);
$resp = $this->command("LOGIN $user $pass");
if (!$resp['ok']) {
throw new RuntimeException('LOGIN failed: ' . $resp['text']);
}
}
public function selectFolder(string $folder = 'INBOX'): array
{
$folder = $this->quoteString($folder);
$resp = $this->command("SELECT $folder");
if (!$resp['ok']) {
throw new RuntimeException('SELECT failed: ' . $resp['text']);
}
// Parse EXISTS count from untagged responses
$exists = 0;
foreach ($resp['untagged'] as $line) {
if (preg_match('/^\* (\d+) EXISTS/', $line, $m)) {
$exists = (int)$m[1];
}
}
return ['exists' => $exists];
}
/** Search returns UIDs of all messages matching criteria. */
public function uidSearch(string $criteria = 'ALL'): array
{
$resp = $this->command("UID SEARCH $criteria");
if (!$resp['ok']) {
throw new RuntimeException('SEARCH failed: ' . $resp['text']);
}
$uids = [];
foreach ($resp['untagged'] as $line) {
if (preg_match('/^\* SEARCH(.*)$/i', $line, $m)) {
preg_match_all('/\d+/', $m[1], $nums);
foreach ($nums[0] as $n) $uids[] = (int)$n;
}
}
return $uids;
}
/**
* Fetch envelope-like headers + spam-relevant fields for a list of UIDs.
* Returns [{uid, subject, from, date, seen, headers: <full-parsed-map>}, ...]
* The `headers` map contains lowercased keys ready for SpamDetector.
*/
public function fetchHeaders(array $uids): array
{
if (!$uids) return [];
sort($uids);
$set = $this->compactUidSet($uids);
// Pull more fields so SpamDetector has signals to work with
$fields = 'FROM SUBJECT DATE REPLY-TO MESSAGE-ID '
. 'X-SPAM-FLAG X-SPAM-STATUS X-SPAM-SCORE '
. 'AUTHENTICATION-RESULTS RECEIVED-SPF '
. 'LIST-UNSUBSCRIBE';
$resp = $this->command("UID FETCH $set (UID FLAGS BODY[HEADER.FIELDS ($fields)])");
if (!$resp['ok']) {
throw new RuntimeException('FETCH headers failed: ' . $resp['text']);
}
$messages = [];
foreach ($resp['fetches'] as $entry) {
$msg = [
'uid' => null,
'subject' => '',
'from' => '',
'date' => '',
'seen' => false,
'headers' => [],
];
if (preg_match('/UID (\d+)/', $entry['preamble'], $m)) $msg['uid'] = (int)$m[1];
if (preg_match('/FLAGS \(([^)]*)\)/', $entry['preamble'], $m)) {
$msg['seen'] = stripos($m[1], '\\Seen') !== false;
}
$headers = $this->parseHeaders($entry['body']);
// Decode MIME-encoded fields used by the UI
$msg['subject'] = $this->decodeMimeHeader($headers['subject'] ?? '');
$msg['from'] = $this->decodeMimeHeader($headers['from'] ?? '');
$msg['date'] = $headers['date'] ?? '';
// Pass the decoded versions back into the header map so SpamDetector
// sees the same strings the user sees
$headers['subject'] = $msg['subject'];
$headers['from'] = $msg['from'];
if (!empty($headers['reply-to'])) {
$headers['reply-to'] = $this->decodeMimeHeader($headers['reply-to']);
}
$msg['headers'] = $headers;
if ($msg['uid']) $messages[] = $msg;
}
return $messages;
}
/**
* Fetch full message body and parse it.
* Returns ['headers' => [...], 'text' => '...', 'html' => '...', 'attachments' => [...]]
*/
public function fetchMessage(int $uid): array
{
$resp = $this->command("UID FETCH $uid (UID BODY.PEEK[])");
if (!$resp['ok']) {
throw new RuntimeException('FETCH body failed: ' . $resp['text']);
}
if (!$resp['fetches']) {
throw new RuntimeException('Message not found');
}
$raw = $resp['fetches'][0]['body'];
return $this->parseMessage($raw);
}
/**
* Mark a list of UIDs as deleted (\Deleted flag) and expunge them.
* After expunge, those messages are gone from the server permanently.
* Returns the number of UIDs that were processed.
*/
public function deleteUids(array $uids): int
{
if (!$uids) return 0;
$set = $this->compactUidSet($uids);
$resp = $this->command("UID STORE $set +FLAGS (\\Deleted)");
if (!$resp['ok']) {
throw new RuntimeException('STORE \\Deleted failed: ' . $resp['text']);
}
$resp = $this->command('EXPUNGE');
if (!$resp['ok']) {
throw new RuntimeException('EXPUNGE failed: ' . $resp['text']);
}
return count($uids);
}
public function logout(): void
{
if ($this->sock) {
try { @$this->command('LOGOUT'); } catch (\Throwable $e) {}
@fclose($this->sock);
$this->sock = null;
}
}
public function __destruct()
{
$this->logout();
}
// ── Low-level protocol ─────────────────────────────────────────
private function nextTag(): string
{
$this->tag++;
return 'A' . str_pad((string)$this->tag, 4, '0', STR_PAD_LEFT);
}
private function writeLine(string $line): void
{
if (fwrite($this->sock, $line . "\r\n") === false) {
throw new RuntimeException('Socket write failed');
}
}
private function readLine(): string
{
$line = fgets($this->sock);
if ($line === false) {
throw new RuntimeException('Socket read failed (timeout?)');
}
return $line;
}
private function readBytes(int $n): string
{
$buf = '';
while (strlen($buf) < $n) {
$chunk = fread($this->sock, $n - strlen($buf));
if ($chunk === false || $chunk === '') {
throw new RuntimeException('Socket read truncated');
}
$buf .= $chunk;
}
return $buf;
}
/**
* Send a command and read all response lines until tagged completion.
* Handles literal continuations ({N}\r\n followed by N bytes).
* Returns ['ok' => bool, 'text' => string, 'untagged' => [...], 'fetches' => [...]]
*/
private function command(string $cmd): array
{
$tag = $this->nextTag();
$this->writeLine("$tag $cmd");
$untagged = [];
$fetches = []; // [{preamble, body}]
$okResult = null;
$okText = '';
while (true) {
$line = $this->readLine();
// Detect literal: {N}\r\n at end → read N more bytes, then continue
$literalBuf = '';
while (preg_match('/\{(\d+)\}\r?\n$/', $line, $m)) {
$bytes = (int)$m[1];
$literalBuf .= $this->readBytes($bytes);
$next = $this->readLine();
$line = rtrim($line, "\r\n") . "\x00LIT\x00" . $literalBuf . "\x00LITEND\x00" . $next;
$literalBuf = '';
}
// Tagged response check
if (strpos($line, $tag . ' ') === 0) {
$rest = substr($line, strlen($tag) + 1);
$okResult = (strpos($rest, 'OK') === 0);
$okText = trim($rest);
break;
}
// Untagged
if (strpos($line, '* ') === 0) {
$untagged[] = trim($line);
// Detect FETCH responses with literal body
if (preg_match('/^\* \d+ FETCH /', $line)) {
if (preg_match('/\x00LIT\x00(.*?)\x00LITEND\x00/s', $line, $m)) {
$body = $m[1];
$preamble = preg_replace('/\x00LIT\x00.*?\x00LITEND\x00.*$/s', '', $line);
$fetches[] = ['preamble' => $preamble, 'body' => $body];
} else {
// No literal, body might be inline (small messages)
$fetches[] = ['preamble' => $line, 'body' => ''];
}
}
}
}
return ['ok' => $okResult, 'text' => $okText, 'untagged' => $untagged, 'fetches' => $fetches];
}
private function quoteString(string $s): string
{
// Simple quoting; passwords with quotes are escaped
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $s) . '"';
}
private function compactUidSet(array $uids): string
{
// For simplicity, just send a comma list (most servers accept this).
// Could be optimized to ranges (1:5,10,15:20).
return implode(',', $uids);
}
// ── MIME parsing ───────────────────────────────────────────────
private function parseHeaders(string $raw): array
{
$headers = [];
$lines = preg_split('/\r?\n/', $raw);
$current = null;
foreach ($lines as $line) {
if ($line === '') break;
if (preg_match('/^([!-9;-~]+):\s*(.*)$/', $line, $m)) {
$current = strtolower($m[1]);
$headers[$current] = $m[2];
} elseif ($current !== null && preg_match('/^[ \t]/', $line)) {
// Continuation
$headers[$current] .= ' ' . trim($line);
}
}
return $headers;
}
public function decodeMimeHeader(string $value): string
{
// Decode =?charset?B?...?= or =?charset?Q?...?=
return preg_replace_callback(
'/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/',
function ($m) {
$charset = strtoupper($m[1]);
$enc = strtoupper($m[2]);
$data = $m[3];
if ($enc === 'B') {
$decoded = base64_decode($data);
} else { // Q
$decoded = quoted_printable_decode(str_replace('_', ' ', $data));
}
if ($charset !== 'UTF-8' && function_exists('mb_convert_encoding')) {
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
}
return $decoded;
},
$value
);
}
/**
* Parse a full RFC 822 message into headers + plain/html bodies.
* Returns ['headers' => [...], 'text' => string, 'html' => string]
*/
public function parseMessage(string $raw): array
{
// Split header / body at first blank line
$pos = strpos($raw, "\r\n\r\n");
if ($pos === false) $pos = strpos($raw, "\n\n");
$headerRaw = $pos !== false ? substr($raw, 0, $pos) : $raw;
$bodyRaw = $pos !== false ? substr($raw, $pos + ($raw[$pos] === "\r" ? 4 : 2)) : '';
$headers = $this->parseHeaders($headerRaw);
$headers['subject'] = $this->decodeMimeHeader($headers['subject'] ?? '');
$headers['from'] = $this->decodeMimeHeader($headers['from'] ?? '');
$headers['to'] = $this->decodeMimeHeader($headers['to'] ?? '');
$contentType = $headers['content-type'] ?? 'text/plain';
$encoding = strtolower($headers['content-transfer-encoding'] ?? '7bit');
$text = '';
$html = '';
if (stripos($contentType, 'multipart/') === 0) {
$boundary = $this->extractBoundary($contentType);
if ($boundary) {
[$text, $html] = $this->parseMultipart($bodyRaw, $boundary);
}
} else {
$decoded = $this->decodeBody($bodyRaw, $encoding);
$charset = $this->extractCharset($contentType);
if ($charset && strtoupper($charset) !== 'UTF-8' && function_exists('mb_convert_encoding')) {
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
}
if (stripos($contentType, 'text/html') !== false) {
$html = $decoded;
} else {
$text = $decoded;
}
}
return ['headers' => $headers, 'text' => $text, 'html' => $html];
}
private function parseMultipart(string $body, string $boundary): array
{
$parts = explode('--' . $boundary, $body);
// First chunk before first boundary, last chunk has -- closing — discard
array_shift($parts);
array_pop($parts);
$text = '';
$html = '';
foreach ($parts as $part) {
$part = ltrim($part, "\r\n");
$pos = strpos($part, "\r\n\r\n");
if ($pos === false) $pos = strpos($part, "\n\n");
if ($pos === false) continue;
$partHeaderRaw = substr($part, 0, $pos);
$partBody = substr($part, $pos + ($part[$pos] === "\r" ? 4 : 2));
$partHeaders = $this->parseHeaders($partHeaderRaw);
$ct = $partHeaders['content-type'] ?? 'text/plain';
$cte = strtolower($partHeaders['content-transfer-encoding'] ?? '7bit');
if (stripos($ct, 'multipart/') === 0) {
$innerBoundary = $this->extractBoundary($ct);
if ($innerBoundary) {
[$nestedText, $nestedHtml] = $this->parseMultipart($partBody, $innerBoundary);
if ($nestedText && !$text) $text = $nestedText;
if ($nestedHtml && !$html) $html = $nestedHtml;
}
continue;
}
$decoded = $this->decodeBody($partBody, $cte);
$charset = $this->extractCharset($ct);
if ($charset && strtoupper($charset) !== 'UTF-8' && function_exists('mb_convert_encoding')) {
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
}
if (stripos($ct, 'text/plain') !== false && !$text) {
$text = $decoded;
} elseif (stripos($ct, 'text/html') !== false && !$html) {
$html = $decoded;
}
}
return [$text, $html];
}
private function decodeBody(string $body, string $encoding): string
{
switch ($encoding) {
case 'base64':
return base64_decode(preg_replace('/\s+/', '', $body)) ?: '';
case 'quoted-printable':
return quoted_printable_decode($body);
default:
return $body;
}
}
private function extractBoundary(string $contentType): ?string
{
if (preg_match('/boundary\s*=\s*"?([^";\s]+)"?/i', $contentType, $m)) {
return $m[1];
}
return null;
}
private function extractCharset(string $contentType): ?string
{
if (preg_match('/charset\s*=\s*"?([^";\s]+)"?/i', $contentType, $m)) {
return $m[1];
}
return null;
}
}