343 lines
10 KiB
PHP
343 lines
10 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
final class Pop3Client
|
|
{
|
|
private string $host;
|
|
private int $port;
|
|
private string $security;
|
|
private int $timeout;
|
|
|
|
/** @var resource|null */
|
|
private $stream = null;
|
|
|
|
public function __construct(string $host, int $port = 110, string $security = 'plain', int $timeout = 12)
|
|
{
|
|
$this->host = $host;
|
|
$this->port = $port;
|
|
$this->security = $security;
|
|
$this->timeout = $timeout;
|
|
}
|
|
|
|
public function connect(): void
|
|
{
|
|
$target = ($this->security === 'ssl' ? 'ssl://' : '') . $this->host;
|
|
$errno = 0;
|
|
$errstr = '';
|
|
$stream = @fsockopen($target, $this->port, $errno, $errstr, $this->timeout);
|
|
|
|
if (!is_resource($stream)) {
|
|
throw new RuntimeException(sprintf('Ne mogu otvoriti POP3 vezu prema %s:%d.', $this->host, $this->port));
|
|
}
|
|
|
|
stream_set_timeout($stream, $this->timeout);
|
|
$this->stream = $stream;
|
|
|
|
$greeting = $this->readLine();
|
|
|
|
if (stripos($greeting, '+OK') !== 0) {
|
|
throw new RuntimeException('POP3 server je odbio početni pozdrav.');
|
|
}
|
|
}
|
|
|
|
public function login(string $username, string $password): void
|
|
{
|
|
$this->simpleCommand('USER ' . $username, 'Korisničko ime nije prihvaćeno na POP3 serveru.');
|
|
$this->simpleCommand('PASS ' . $password, 'Lozinka nije prihvaćena na POP3 serveru.');
|
|
}
|
|
|
|
public function stat(): array
|
|
{
|
|
$response = $this->simpleCommand('STAT', 'Ne mogu očitati stanje inboxa.');
|
|
|
|
if (preg_match('/^\+OK\s+(\d+)\s+(\d+)/', $response, $matches)) {
|
|
return [
|
|
'count' => (int) $matches[1],
|
|
'size' => (int) $matches[2],
|
|
];
|
|
}
|
|
|
|
return ['count' => 0, 'size' => 0];
|
|
}
|
|
|
|
public function fetchRecent(int $limit = 15): array
|
|
{
|
|
$stats = $this->stat();
|
|
$count = $stats['count'];
|
|
|
|
if ($count <= 0) {
|
|
return [];
|
|
}
|
|
|
|
$messages = [];
|
|
$start = max(1, $count - $limit + 1);
|
|
|
|
for ($number = $count; $number >= $start; $number--) {
|
|
try {
|
|
$raw = $this->multilineCommand('TOP ' . $number . ' 18', 'Ne mogu dohvatiti pregled poruke.');
|
|
} catch (Throwable $exception) {
|
|
$raw = $this->multilineCommand('RETR ' . $number, 'Ne mogu preuzeti poruku s POP3 servera.');
|
|
}
|
|
|
|
$messages[] = $this->parseMessage($number, $raw, false);
|
|
}
|
|
|
|
return $messages;
|
|
}
|
|
|
|
public function fetchMessage(int $number): array
|
|
{
|
|
$raw = $this->multilineCommand('RETR ' . $number, 'Ne mogu otvoriti traženu poruku.');
|
|
|
|
return $this->parseMessage($number, $raw, true);
|
|
}
|
|
|
|
public function quit(): void
|
|
{
|
|
if (is_resource($this->stream)) {
|
|
try {
|
|
$this->simpleCommand('QUIT');
|
|
} catch (Throwable $exception) {
|
|
// ignore close failures
|
|
}
|
|
|
|
fclose($this->stream);
|
|
$this->stream = null;
|
|
}
|
|
}
|
|
|
|
public function __destruct()
|
|
{
|
|
$this->quit();
|
|
}
|
|
|
|
private function parseMessage(int $number, string $rawMessage, bool $includeBody): array
|
|
{
|
|
[$headerText, $bodyText] = self::splitMessage($rawMessage);
|
|
$headers = self::parseHeaders($headerText);
|
|
$decodedBody = self::extractBodyText($headers, $bodyText);
|
|
$normalizedBody = trim(preg_replace("/
|
|
?|
|
|
/", "
|
|
", $decodedBody) ?? $decodedBody);
|
|
$preview = truncate_text($normalizedBody !== '' ? $normalizedBody : 'Nema pregleda za ovu poruku.', 180);
|
|
|
|
return [
|
|
'number' => $number,
|
|
'subject' => self::headerValue($headers, 'subject', '(Bez naslova)'),
|
|
'from' => self::headerValue($headers, 'from', 'Nepoznati pošiljatelj'),
|
|
'date' => self::headerValue($headers, 'date', ''),
|
|
'message_id' => self::headerValue($headers, 'message-id', 'POP3-' . $number),
|
|
'preview' => $preview !== '' ? $preview : 'Nema pregleda za ovu poruku.',
|
|
'body_text' => $includeBody ? ($normalizedBody !== '' ? $normalizedBody : 'Poruka nema tekstualni sadržaj za prikaz.') : '',
|
|
];
|
|
}
|
|
|
|
private static function splitMessage(string $rawMessage): array
|
|
{
|
|
$parts = preg_split("/
|
|
?
|
|
|
|
?
|
|
/", $rawMessage, 2);
|
|
|
|
return [
|
|
$parts[0] ?? '',
|
|
$parts[1] ?? '',
|
|
];
|
|
}
|
|
|
|
public static function parseHeaders(string $headerText): array
|
|
{
|
|
$headers = [];
|
|
$current = null;
|
|
$lines = preg_split("/
|
|
?
|
|
/", $headerText) ?: [];
|
|
|
|
foreach ($lines as $line) {
|
|
if ($line === '') {
|
|
continue;
|
|
}
|
|
|
|
if (preg_match('/^[ ]+/', $line) === 1 && $current !== null) {
|
|
$headers[$current] .= ' ' . trim($line);
|
|
continue;
|
|
}
|
|
|
|
$parts = explode(':', $line, 2);
|
|
|
|
if (count($parts) !== 2) {
|
|
continue;
|
|
}
|
|
|
|
$current = strtolower(trim($parts[0]));
|
|
$headers[$current] = trim($parts[1]);
|
|
}
|
|
|
|
return $headers;
|
|
}
|
|
|
|
private static function extractBodyText(array $headers, string $body): string
|
|
{
|
|
$contentType = strtolower((string) ($headers['content-type'] ?? 'text/plain; charset=UTF-8'));
|
|
$encoding = strtolower((string) ($headers['content-transfer-encoding'] ?? ''));
|
|
$charset = 'UTF-8';
|
|
|
|
if (preg_match('/charset="?([^";]+)"?/i', $contentType, $charsetMatch) === 1) {
|
|
$charset = trim($charsetMatch[1]);
|
|
}
|
|
|
|
if (str_starts_with($contentType, 'multipart/') && preg_match('/boundary="?([^";]+)"?/i', $contentType, $boundaryMatch) === 1) {
|
|
$boundary = $boundaryMatch[1];
|
|
$delimiter = '--' . $boundary;
|
|
$parts = explode($delimiter, $body);
|
|
$plain = '';
|
|
$html = '';
|
|
|
|
foreach ($parts as $part) {
|
|
$part = ltrim($part, "
|
|
");
|
|
$part = preg_replace('/--\s*$/', '', $part) ?? $part;
|
|
|
|
if (trim($part) === '') {
|
|
continue;
|
|
}
|
|
|
|
[$partHeadersText, $partBody] = self::splitMessage($part);
|
|
$partHeaders = self::parseHeaders($partHeadersText);
|
|
$partText = trim(self::extractBodyText($partHeaders, $partBody));
|
|
$partType = strtolower((string) ($partHeaders['content-type'] ?? 'text/plain'));
|
|
|
|
if ($partText === '') {
|
|
continue;
|
|
}
|
|
|
|
if (str_contains($partType, 'text/plain')) {
|
|
return $partText;
|
|
}
|
|
|
|
if ($plain === '') {
|
|
$plain = $partText;
|
|
}
|
|
|
|
if ($html === '' && str_contains($partType, 'text/html')) {
|
|
$html = $partText;
|
|
}
|
|
}
|
|
|
|
return $plain !== '' ? $plain : $html;
|
|
}
|
|
|
|
$decoded = self::decodeBody($body, $encoding);
|
|
|
|
if ($charset !== '' && strtoupper($charset) !== 'UTF-8' && function_exists('iconv')) {
|
|
$converted = @iconv($charset, 'UTF-8//IGNORE', $decoded);
|
|
|
|
if ($converted !== false) {
|
|
$decoded = $converted;
|
|
}
|
|
}
|
|
|
|
if (str_contains($contentType, 'text/html')) {
|
|
$decoded = html_entity_decode(strip_tags($decoded), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
}
|
|
|
|
return trim($decoded);
|
|
}
|
|
|
|
private static function decodeBody(string $body, string $encoding): string
|
|
{
|
|
return match ($encoding) {
|
|
'base64' => base64_decode($body, true) ?: $body,
|
|
'quoted-printable' => quoted_printable_decode($body),
|
|
default => $body,
|
|
};
|
|
}
|
|
|
|
private static function headerValue(array $headers, string $key, string $fallback): string
|
|
{
|
|
$value = trim((string) ($headers[$key] ?? ''));
|
|
|
|
if ($value === '') {
|
|
return $fallback;
|
|
}
|
|
|
|
if (function_exists('iconv_mime_decode')) {
|
|
$decoded = @iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8');
|
|
|
|
if (is_string($decoded) && $decoded !== '') {
|
|
return $decoded;
|
|
}
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function simpleCommand(string $command, ?string $fallbackMessage = null): string
|
|
{
|
|
$this->write($command . "
|
|
");
|
|
$response = $this->readLine();
|
|
|
|
if (stripos($response, '+OK') !== 0) {
|
|
$serverMessage = preg_replace('/^-ERR\s*/i', '', trim($response)) ?: trim($response);
|
|
throw new RuntimeException($fallbackMessage ?: ($serverMessage !== '' ? $serverMessage : 'POP3 naredba nije uspjela.'));
|
|
}
|
|
|
|
return trim($response);
|
|
}
|
|
|
|
private function multilineCommand(string $command, ?string $fallbackMessage = null): string
|
|
{
|
|
$this->write($command . "
|
|
");
|
|
$response = $this->readLine();
|
|
|
|
if (stripos($response, '+OK') !== 0) {
|
|
$serverMessage = preg_replace('/^-ERR\s*/i', '', trim($response)) ?: trim($response);
|
|
throw new RuntimeException($fallbackMessage ?: ($serverMessage !== '' ? $serverMessage : 'POP3 naredba nije uspjela.'));
|
|
}
|
|
|
|
$lines = [];
|
|
|
|
while (($line = $this->readLine()) !== '.') {
|
|
if (str_starts_with($line, '..')) {
|
|
$line = substr($line, 1);
|
|
}
|
|
|
|
$lines[] = $line;
|
|
}
|
|
|
|
return implode("
|
|
", $lines);
|
|
}
|
|
|
|
private function write(string $payload): void
|
|
{
|
|
if (!is_resource($this->stream)) {
|
|
throw new RuntimeException('POP3 veza nije aktivna.');
|
|
}
|
|
|
|
fwrite($this->stream, $payload);
|
|
}
|
|
|
|
private function readLine(): string
|
|
{
|
|
if (!is_resource($this->stream)) {
|
|
throw new RuntimeException('POP3 veza nije aktivna.');
|
|
}
|
|
|
|
$line = fgets($this->stream, 8192);
|
|
|
|
if ($line === false) {
|
|
$meta = stream_get_meta_data($this->stream);
|
|
|
|
if (!empty($meta['timed_out'])) {
|
|
throw new RuntimeException('POP3 server nije odgovorio na vrijeme.');
|
|
}
|
|
|
|
throw new RuntimeException('POP3 server je zatvorio vezu.');
|
|
}
|
|
|
|
return rtrim($line, "
|
|
");
|
|
}
|
|
}
|