Rewrite ImapClient to use raw sockets instead of php-imap extension
Production server doesn't have php-imap extension. Replaced the entire ImapClient class to use stream_socket_client() with raw IMAP protocol commands. Supports SSL/TLS, STARTTLS, MIME header decoding, body extraction with encoding detection, and charset conversion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
417
api.php
417
api.php
@@ -117,10 +117,12 @@ function sendMail(string $to, string $subject, string $htmlBody): bool {
|
||||
return mail($to, $subject, $htmlBody, $headers, '-f ' . MAIL_FROM);
|
||||
}
|
||||
|
||||
// ==================== IMAP CLIENT ====================
|
||||
// ==================== IMAP CLIENT (socket-pohjainen, ei vaadi php-imap) ====================
|
||||
|
||||
class ImapClient {
|
||||
private $connection = null;
|
||||
private $socket = null;
|
||||
private int $tagCounter = 0;
|
||||
public string $lastError = '';
|
||||
|
||||
public function connect(array $config): bool {
|
||||
$host = $config['imap_host'] ?? '';
|
||||
@@ -130,83 +132,133 @@ class ImapClient {
|
||||
$encryption = $config['imap_encryption'] ?? 'ssl';
|
||||
|
||||
if (empty($host) || empty($user) || empty($pass)) {
|
||||
$this->lastError = 'IMAP-asetukset puuttuvat';
|
||||
return false;
|
||||
}
|
||||
|
||||
$mailbox = '{' . $host . ':' . $port . '/imap/' . $encryption . '}INBOX';
|
||||
$prefix = ($encryption === 'ssl') ? 'ssl://' : 'tcp://';
|
||||
$context = stream_context_create([
|
||||
'ssl' => ['verify_peer' => false, 'verify_peer_name' => false]
|
||||
]);
|
||||
|
||||
// Suppress warnings, handle errors manually
|
||||
$this->connection = @imap_open($mailbox, $user, $pass, 0, 1);
|
||||
$this->socket = @stream_socket_client(
|
||||
$prefix . $host . ':' . $port,
|
||||
$errno, $errstr, 15,
|
||||
STREAM_CLIENT_CONNECT, $context
|
||||
);
|
||||
|
||||
if (!$this->connection) {
|
||||
if (!$this->socket) {
|
||||
$this->lastError = "Yhteys epäonnistui: {$errstr} ({$errno})";
|
||||
return false;
|
||||
}
|
||||
|
||||
stream_set_timeout($this->socket, 30);
|
||||
|
||||
// Read greeting
|
||||
$greeting = $this->readLine();
|
||||
if (!$greeting || strpos($greeting, '* OK') === false) {
|
||||
$this->lastError = 'Palvelin ei vastannut oikein: ' . $greeting;
|
||||
$this->disconnect();
|
||||
return false;
|
||||
}
|
||||
|
||||
// STARTTLS if needed
|
||||
if ($encryption === 'tls') {
|
||||
$resp = $this->command('STARTTLS');
|
||||
if (!$this->isOk($resp)) {
|
||||
$this->lastError = 'STARTTLS epäonnistui';
|
||||
$this->disconnect();
|
||||
return false;
|
||||
}
|
||||
if (!stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
$this->lastError = 'TLS-neuvottelu epäonnistui';
|
||||
$this->disconnect();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Login
|
||||
$resp = $this->command('LOGIN "' . $this->escape($user) . '" "' . $this->escape($pass) . '"');
|
||||
if (!$this->isOk($resp)) {
|
||||
$this->lastError = 'Kirjautuminen epäonnistui: väärä tunnus tai salasana';
|
||||
$this->disconnect();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Select INBOX
|
||||
$resp = $this->command('SELECT INBOX');
|
||||
if (!$this->isOk($resp)) {
|
||||
$this->lastError = 'INBOX:n avaus epäonnistui';
|
||||
$this->disconnect();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function fetchMessages(int $limit = 50): array {
|
||||
if (!$this->connection) return [];
|
||||
if (!$this->socket) return [];
|
||||
|
||||
// Get message count from STATUS
|
||||
$resp = $this->command('STATUS INBOX (MESSAGES)');
|
||||
$totalMessages = 0;
|
||||
foreach ($resp as $line) {
|
||||
if (preg_match('/MESSAGES\s+(\d+)/i', $line, $m)) {
|
||||
$totalMessages = intval($m[1]);
|
||||
}
|
||||
}
|
||||
if ($totalMessages === 0) return [];
|
||||
|
||||
$start = max(1, $totalMessages - $limit + 1);
|
||||
$range = $start . ':' . $totalMessages;
|
||||
|
||||
// Fetch headers for range
|
||||
$resp = $this->command("FETCH {$range} (BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE MESSAGE-ID IN-REPLY-TO REFERENCES)] BODY.PEEK[TEXT] FLAGS)");
|
||||
|
||||
$messages = [];
|
||||
$check = imap_check($this->connection);
|
||||
if (!$check || $check->Nmsgs === 0) return [];
|
||||
$current = null;
|
||||
$headerBuf = '';
|
||||
$bodyBuf = '';
|
||||
$readingHeader = false;
|
||||
$readingBody = false;
|
||||
$headerBytesLeft = 0;
|
||||
$bodyBytesLeft = 0;
|
||||
|
||||
$start = max(1, $check->Nmsgs - $limit + 1);
|
||||
$end = $check->Nmsgs;
|
||||
|
||||
for ($i = $end; $i >= $start; $i--) {
|
||||
$header = @imap_headerinfo($this->connection, $i);
|
||||
if (!$header) continue;
|
||||
|
||||
$overview = @imap_fetch_overview($this->connection, strval($i), 0);
|
||||
|
||||
// Decode subject
|
||||
$subject = '';
|
||||
if (isset($header->subject)) {
|
||||
$decoded = imap_mime_header_decode($header->subject);
|
||||
foreach ($decoded as $part) {
|
||||
$subject .= $part->text;
|
||||
}
|
||||
// Simpler approach: fetch one-by-one for reliability
|
||||
$messages = [];
|
||||
for ($i = $totalMessages; $i >= $start; $i--) {
|
||||
$msg = $this->fetchSingleMessage($i);
|
||||
if ($msg) $messages[] = $msg;
|
||||
}
|
||||
|
||||
// From
|
||||
$fromEmail = '';
|
||||
$fromName = '';
|
||||
if (isset($header->from[0])) {
|
||||
$fromEmail = $header->from[0]->mailbox . '@' . ($header->from[0]->host ?? '');
|
||||
if (isset($header->from[0]->personal)) {
|
||||
$decoded = imap_mime_header_decode($header->from[0]->personal);
|
||||
foreach ($decoded as $part) {
|
||||
$fromName .= $part->text;
|
||||
}
|
||||
}
|
||||
return $messages;
|
||||
}
|
||||
|
||||
// Message-ID
|
||||
$messageId = isset($header->message_id) ? trim($header->message_id) : '';
|
||||
private function fetchSingleMessage(int $num): ?array {
|
||||
// Fetch headers
|
||||
$resp = $this->command("FETCH {$num} BODY.PEEK[HEADER]");
|
||||
$headerRaw = $this->extractLiteral($resp);
|
||||
|
||||
// In-Reply-To
|
||||
$inReplyTo = '';
|
||||
if (isset($header->in_reply_to)) {
|
||||
$inReplyTo = trim($header->in_reply_to);
|
||||
}
|
||||
if (!$headerRaw) return null;
|
||||
|
||||
// References
|
||||
$references = '';
|
||||
if (isset($header->references)) {
|
||||
$references = trim($header->references);
|
||||
}
|
||||
$headers = $this->parseHeaders($headerRaw);
|
||||
$subject = $this->decodeMimeHeader($headers['subject'] ?? '');
|
||||
$fromRaw = $headers['from'] ?? '';
|
||||
$fromParsed = $this->parseFrom($fromRaw);
|
||||
$messageId = trim($headers['message-id'] ?? '');
|
||||
$inReplyTo = trim($headers['in-reply-to'] ?? '');
|
||||
$references = trim($headers['references'] ?? '');
|
||||
$dateStr = $headers['date'] ?? '';
|
||||
$date = $dateStr ? @date('Y-m-d H:i:s', strtotime($dateStr)) : date('Y-m-d H:i:s');
|
||||
if (!$date) $date = date('Y-m-d H:i:s');
|
||||
|
||||
// Date
|
||||
$date = isset($header->date) ? date('Y-m-d H:i:s', strtotime($header->date)) : date('Y-m-d H:i:s');
|
||||
// Fetch body (text part)
|
||||
$body = $this->fetchBody($num);
|
||||
|
||||
// Body — prefer plain text
|
||||
$body = $this->getBody($i);
|
||||
|
||||
$messages[] = [
|
||||
return [
|
||||
'subject' => $subject,
|
||||
'from_email' => $fromEmail,
|
||||
'from_name' => $fromName,
|
||||
'from_email' => $fromParsed['email'],
|
||||
'from_name' => $this->decodeMimeHeader($fromParsed['name']),
|
||||
'message_id' => $messageId,
|
||||
'in_reply_to' => $inReplyTo,
|
||||
'references' => $references,
|
||||
@@ -215,75 +267,219 @@ class ImapClient {
|
||||
];
|
||||
}
|
||||
|
||||
return $messages;
|
||||
private function fetchBody(int $num): string {
|
||||
// Try text/plain first via BODYSTRUCTURE
|
||||
$resp = $this->command("FETCH {$num} BODYSTRUCTURE");
|
||||
$structLine = implode(' ', $resp);
|
||||
|
||||
// Simple approach: fetch BODY[1] (usually text/plain in multipart)
|
||||
// or BODY[TEXT] for simple messages
|
||||
$resp = $this->command("FETCH {$num} BODY.PEEK[1]");
|
||||
$body = $this->extractLiteral($resp);
|
||||
|
||||
if (!$body) {
|
||||
// Fallback: full text
|
||||
$resp = $this->command("FETCH {$num} BODY.PEEK[TEXT]");
|
||||
$body = $this->extractLiteral($resp);
|
||||
}
|
||||
|
||||
private function getBody(int $msgNum): string {
|
||||
if (!$this->connection) return '';
|
||||
if (!$body) return '';
|
||||
|
||||
$structure = @imap_fetchstructure($this->connection, $msgNum);
|
||||
if (!$structure) return '';
|
||||
|
||||
// Simple message (no parts)
|
||||
if (empty($structure->parts)) {
|
||||
$body = imap_fetchbody($this->connection, $msgNum, '1');
|
||||
return $this->decodeBody($body, $structure->encoding ?? 0);
|
||||
// Detect encoding from BODYSTRUCTURE
|
||||
$encoding = '';
|
||||
// Parse BODYSTRUCTURE for encoding (7BIT, BASE64, QUOTED-PRINTABLE)
|
||||
if (preg_match('/"TEXT"\s+"PLAIN"\s+\([^)]*\)\s+NIL\s+NIL\s+"([^"]+)"/i', $structLine, $em)) {
|
||||
$encoding = strtoupper($em[1]);
|
||||
} elseif (preg_match('/BODY\[1\].*?"([^"]+)"/i', $structLine, $em)) {
|
||||
$encoding = strtoupper($em[1]);
|
||||
}
|
||||
|
||||
// Multipart — look for text/plain first, then text/html
|
||||
$plainBody = '';
|
||||
$htmlBody = '';
|
||||
|
||||
foreach ($structure->parts as $partNum => $part) {
|
||||
$partIndex = strval($partNum + 1);
|
||||
|
||||
if ($part->type === 0) { // TEXT
|
||||
$subtype = strtolower($part->subtype ?? '');
|
||||
$body = imap_fetchbody($this->connection, $msgNum, $partIndex);
|
||||
$decoded = $this->decodeBody($body, $part->encoding ?? 0);
|
||||
|
||||
// Handle charset
|
||||
$charset = $this->getCharset($part);
|
||||
if ($charset && strtolower($charset) !== 'utf-8') {
|
||||
$converted = @iconv($charset, 'UTF-8//IGNORE', $decoded);
|
||||
if ($converted !== false) $decoded = $converted;
|
||||
}
|
||||
|
||||
if ($subtype === 'plain') {
|
||||
$plainBody = $decoded;
|
||||
} elseif ($subtype === 'html') {
|
||||
$htmlBody = $decoded;
|
||||
// Try to detect encoding from body content if not found
|
||||
if (!$encoding) {
|
||||
// Check if it looks like base64
|
||||
if (preg_match('/^[A-Za-z0-9+\/=\s]+$/', trim($body)) && strlen(trim($body)) > 50) {
|
||||
$decoded = @base64_decode($body, true);
|
||||
if ($decoded !== false && strlen($decoded) > 0) {
|
||||
// Verify it produces readable text
|
||||
if (preg_match('/[\x20-\x7E\xC0-\xFF]/', $decoded)) {
|
||||
$body = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($plainBody) return trim($plainBody);
|
||||
if ($htmlBody) return trim(strip_tags($htmlBody));
|
||||
return '';
|
||||
}
|
||||
|
||||
private function decodeBody(string $body, int $encoding): string {
|
||||
switch ($encoding) {
|
||||
case 3: return base64_decode($body); // BASE64
|
||||
case 4: return quoted_printable_decode($body); // QUOTED-PRINTABLE
|
||||
default: return $body;
|
||||
} else {
|
||||
if ($encoding === 'BASE64') {
|
||||
$body = base64_decode($body);
|
||||
} elseif ($encoding === 'QUOTED-PRINTABLE') {
|
||||
$body = quoted_printable_decode($body);
|
||||
}
|
||||
}
|
||||
|
||||
private function getCharset($part): string {
|
||||
if (!isset($part->parameters)) return '';
|
||||
foreach ($part->parameters as $param) {
|
||||
if (strtolower($param->attribute) === 'charset') {
|
||||
return $param->value;
|
||||
// Strip HTML if it looks like HTML
|
||||
if (preg_match('/<html|<body|<div|<p\b/i', $body)) {
|
||||
$body = strip_tags($body);
|
||||
// Clean up whitespace
|
||||
$body = preg_replace('/\n{3,}/', "\n\n", $body);
|
||||
}
|
||||
|
||||
// Try charset conversion
|
||||
if (preg_match('/charset[="\s]+([^\s;"]+)/i', $structLine, $cm)) {
|
||||
$charset = strtolower(trim($cm[1], '"'));
|
||||
if ($charset && $charset !== 'utf-8') {
|
||||
$converted = @iconv($charset, 'UTF-8//IGNORE', $body);
|
||||
if ($converted !== false) $body = $converted;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
|
||||
return trim($body);
|
||||
}
|
||||
|
||||
private function parseHeaders(string $raw): array {
|
||||
$headers = [];
|
||||
$lines = explode("\n", str_replace("\r\n", "\n", $raw));
|
||||
$lastKey = '';
|
||||
foreach ($lines as $line) {
|
||||
if ($line === '' || $line === "\r") continue;
|
||||
// Continuation line (starts with space/tab)
|
||||
if (preg_match('/^[\s\t]+(.+)/', $line, $m)) {
|
||||
if ($lastKey && isset($headers[$lastKey])) {
|
||||
$headers[$lastKey] .= ' ' . trim($m[1]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^([A-Za-z\-]+):\s*(.*)$/', $line, $m)) {
|
||||
$key = strtolower($m[1]);
|
||||
$headers[$key] = trim($m[2]);
|
||||
$lastKey = $key;
|
||||
}
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
|
||||
private function parseFrom(string $from): array {
|
||||
$from = trim($from);
|
||||
if (preg_match('/^"?([^"<]*)"?\s*<([^>]+)>/', $from, $m)) {
|
||||
return ['name' => trim($m[1], ' "'), 'email' => trim($m[2])];
|
||||
}
|
||||
if (preg_match('/^([^\s@]+@[^\s@]+)/', $from, $m)) {
|
||||
return ['name' => '', 'email' => $m[1]];
|
||||
}
|
||||
return ['name' => '', 'email' => $from];
|
||||
}
|
||||
|
||||
private function decodeMimeHeader(string $str): string {
|
||||
if (strpos($str, '=?') === false) return trim($str);
|
||||
$decoded = '';
|
||||
$parts = preg_split('/(=\?[^\?]+\?[BbQq]\?[^\?]*\?=)/', $str, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
foreach ($parts as $part) {
|
||||
if (preg_match('/^=\?([^\?]+)\?([BbQq])\?([^\?]*)\?=$/', $part, $m)) {
|
||||
$charset = $m[1];
|
||||
$encoding = strtoupper($m[2]);
|
||||
$text = $m[3];
|
||||
if ($encoding === 'B') {
|
||||
$text = base64_decode($text);
|
||||
} elseif ($encoding === 'Q') {
|
||||
$text = quoted_printable_decode(str_replace('_', ' ', $text));
|
||||
}
|
||||
if (strtolower($charset) !== 'utf-8') {
|
||||
$converted = @iconv($charset, 'UTF-8//IGNORE', $text);
|
||||
if ($converted !== false) $text = $converted;
|
||||
}
|
||||
$decoded .= $text;
|
||||
} else {
|
||||
// Remove whitespace between encoded words
|
||||
if (trim($part) === '') continue;
|
||||
$decoded .= $part;
|
||||
}
|
||||
}
|
||||
return trim($decoded);
|
||||
}
|
||||
|
||||
private function command(string $cmd): array {
|
||||
$tag = 'A' . (++$this->tagCounter);
|
||||
$this->writeLine("{$tag} {$cmd}");
|
||||
|
||||
$response = [];
|
||||
while (true) {
|
||||
$line = $this->readLine();
|
||||
if ($line === false || $line === null) break;
|
||||
$response[] = $line;
|
||||
|
||||
// Check for literal {N} — read N bytes
|
||||
if (preg_match('/\{(\d+)\}$/', $line, $m)) {
|
||||
$bytes = intval($m[1]);
|
||||
$data = $this->readBytes($bytes);
|
||||
$response[] = $data;
|
||||
// Read the closing line after literal
|
||||
$closingLine = $this->readLine();
|
||||
if ($closingLine !== false && $closingLine !== null) {
|
||||
$response[] = $closingLine;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tagged response = done
|
||||
if (strpos($line, $tag . ' ') === 0) break;
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function extractLiteral(array $resp): string {
|
||||
$result = '';
|
||||
for ($i = 0; $i < count($resp); $i++) {
|
||||
if (preg_match('/\{(\d+)\}$/', $resp[$i], $m)) {
|
||||
// Next element should be the literal data
|
||||
if (isset($resp[$i + 1])) {
|
||||
$result .= $resp[$i + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function isOk(array $resp): bool {
|
||||
foreach ($resp as $line) {
|
||||
if (preg_match('/^A\d+\s+OK/i', $line)) return true;
|
||||
if (preg_match('/^A\d+\s+(NO|BAD)/i', $line)) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function escape(string $str): string {
|
||||
return str_replace(['\\', '"'], ['\\\\', '\\"'], $str);
|
||||
}
|
||||
|
||||
private function writeLine(string $line): void {
|
||||
if (!$this->socket) return;
|
||||
fwrite($this->socket, $line . "\r\n");
|
||||
}
|
||||
|
||||
private function readLine(): ?string {
|
||||
if (!$this->socket) return null;
|
||||
$line = fgets($this->socket, 8192);
|
||||
if ($line === false) return null;
|
||||
return rtrim($line, "\r\n");
|
||||
}
|
||||
|
||||
private function readBytes(int $n): string {
|
||||
if (!$this->socket) return '';
|
||||
$data = '';
|
||||
$remaining = $n;
|
||||
while ($remaining > 0) {
|
||||
$chunk = fread($this->socket, min($remaining, 8192));
|
||||
if ($chunk === false || $chunk === '') break;
|
||||
$data .= $chunk;
|
||||
$remaining -= strlen($chunk);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function disconnect(): void {
|
||||
if ($this->connection) {
|
||||
@imap_close($this->connection);
|
||||
$this->connection = null;
|
||||
if ($this->socket) {
|
||||
try {
|
||||
$this->command('LOGOUT');
|
||||
} catch (\Throwable $e) {}
|
||||
@fclose($this->socket);
|
||||
$this->socket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1282,9 +1478,8 @@ switch ($action) {
|
||||
|
||||
$imap = new ImapClient();
|
||||
if (!$imap->connect($config)) {
|
||||
$errors = imap_errors();
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'IMAP-yhteys epäonnistui' . ($errors ? ': ' . implode(', ', $errors) : '')]);
|
||||
echo json_encode(['error' => 'IMAP-yhteys epäonnistui: ' . $imap->lastError]);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user