correo
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* CorreoImapService
|
||||
*
|
||||
* Lee correos vía protocolo IMAP puro usando sockets PHP.
|
||||
* No requiere la extensión imap de PHP.
|
||||
*/
|
||||
class CorreoImapService
|
||||
{
|
||||
private $socket = null;
|
||||
private int $tag = 1;
|
||||
private string $lastError = '';
|
||||
|
||||
public function __construct(
|
||||
private string $host,
|
||||
private int $port,
|
||||
private bool $useSsl,
|
||||
private string $user,
|
||||
private string $password,
|
||||
private string $folder = 'INBOX',
|
||||
private int $timeout = 15
|
||||
) {}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// API pública
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Conecta, autentica y devuelve los últimos $limit correos.
|
||||
* Cada elemento: [uid, from, subject, date, body]
|
||||
*/
|
||||
public function fetchLatest(int $limit = 5): array
|
||||
{
|
||||
$this->connect();
|
||||
$this->login();
|
||||
$total = $this->selectFolder();
|
||||
|
||||
if ($total === 0) {
|
||||
$this->logout();
|
||||
return [];
|
||||
}
|
||||
|
||||
// Tomamos los últimos $limit números de secuencia
|
||||
$from = max(1, $total - $limit + 1);
|
||||
$set = "{$from}:{$total}";
|
||||
|
||||
$emails = $this->fetchMessages($set);
|
||||
|
||||
$this->logout();
|
||||
|
||||
// Devolver en orden descendente (más reciente primero)
|
||||
return array_reverse($emails);
|
||||
}
|
||||
|
||||
public function getLastError(): string
|
||||
{
|
||||
return $this->lastError;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Conexión y autenticación
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
private function connect(): void
|
||||
{
|
||||
$address = $this->useSsl
|
||||
? "ssl://{$this->host}:{$this->port}"
|
||||
: "tcp://{$this->host}:{$this->port}";
|
||||
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->socket = stream_socket_client(
|
||||
$address,
|
||||
$errno,
|
||||
$errstr,
|
||||
$this->timeout,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$context
|
||||
);
|
||||
|
||||
if (! $this->socket) {
|
||||
throw new \RuntimeException("No se pudo conectar a {$address}: {$errstr} ({$errno})");
|
||||
}
|
||||
|
||||
stream_set_timeout($this->socket, $this->timeout);
|
||||
|
||||
// Leer el banner de bienvenida del servidor
|
||||
$this->readLine();
|
||||
}
|
||||
|
||||
private function login(): void
|
||||
{
|
||||
$response = $this->command("LOGIN \"{$this->user}\" \"{$this->password}\"");
|
||||
|
||||
if (! str_contains($response, 'OK')) {
|
||||
throw new \RuntimeException("Autenticación fallida. Respuesta: {$response}");
|
||||
}
|
||||
}
|
||||
|
||||
private function logout(): void
|
||||
{
|
||||
if ($this->socket) {
|
||||
$this->command('LOGOUT');
|
||||
fclose($this->socket);
|
||||
$this->socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Operaciones IMAP
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Selecciona la carpeta y retorna el número total de mensajes.
|
||||
*/
|
||||
private function selectFolder(): int
|
||||
{
|
||||
$response = $this->command("SELECT \"{$this->folder}\"");
|
||||
|
||||
// Buscar "* N EXISTS"
|
||||
if (preg_match('/\*\s+(\d+)\s+EXISTS/i', $response, $m)) {
|
||||
return (int) $m[1];
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarga cabeceras + cuerpo de un rango de secuencia (ej: "1:5").
|
||||
*/
|
||||
private function fetchMessages(string $set): array
|
||||
{
|
||||
// Pedimos FLAGS, ENVELOPE y BODY para extraer remitente, asunto y cuerpo
|
||||
$raw = $this->command("FETCH {$set} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] BODY.PEEK[TEXT])");
|
||||
|
||||
return $this->parseMessages($raw);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Parser de respuesta FETCH
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
private function parseMessages(string $raw): array
|
||||
{
|
||||
$emails = [];
|
||||
|
||||
// Dividir por inicio de cada mensaje "* N FETCH"
|
||||
$blocks = preg_split('/\* \d+ FETCH /i', $raw, -1, PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
foreach ($blocks as $block) {
|
||||
$email = [
|
||||
'seq' => '',
|
||||
'from' => '',
|
||||
'subject' => '',
|
||||
'date' => '',
|
||||
'body' => '',
|
||||
];
|
||||
|
||||
// FROM
|
||||
if (preg_match('/^From:\s*(.+)$/mi', $block, $m)) {
|
||||
$email['from'] = $this->decodeMimeHeader(trim($m[1]));
|
||||
}
|
||||
|
||||
// SUBJECT
|
||||
if (preg_match('/^Subject:\s*(.+)$/mi', $block, $m)) {
|
||||
$email['subject'] = $this->decodeMimeHeader(trim($m[1]));
|
||||
}
|
||||
|
||||
// DATE
|
||||
if (preg_match('/^Date:\s*(.+)$/mi', $block, $m)) {
|
||||
$email['date'] = trim($m[1]);
|
||||
}
|
||||
|
||||
// BODY: tomamos el texto entre las dos secciones BODY
|
||||
// La respuesta tiene dos literales {N}\r\n<contenido>
|
||||
$parts = preg_split('/\}\r?\n/', $block);
|
||||
if (count($parts) >= 3) {
|
||||
// La tercera sección suele ser el cuerpo de texto
|
||||
$rawBody = $parts[2];
|
||||
// Limpiar hasta el fin del literal (antes del siguiente *)
|
||||
$rawBody = preg_replace('/\r?\n\)\s*\*.*$/s', '', $rawBody);
|
||||
$rawBody = preg_replace('/\r?\n\)\s*[A-Z0-9]+ OK.*/s', '', $rawBody);
|
||||
$email['body'] = $this->cleanBody(trim($rawBody));
|
||||
} elseif (count($parts) === 2) {
|
||||
$rawBody = $parts[1];
|
||||
$rawBody = preg_replace('/\r?\n\).*$/s', '', $rawBody);
|
||||
$email['body'] = $this->cleanBody(trim($rawBody));
|
||||
}
|
||||
|
||||
if ($email['from'] || $email['subject']) {
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
|
||||
return $emails;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Comunicación con el socket
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
private function command(string $cmd): string
|
||||
{
|
||||
$tag = 'A' . str_pad((string) $this->tag++, 4, '0', STR_PAD_LEFT);
|
||||
fwrite($this->socket, "{$tag} {$cmd}\r\n");
|
||||
|
||||
$response = '';
|
||||
while (! feof($this->socket)) {
|
||||
$line = fgets($this->socket, 8192);
|
||||
if ($line === false) {
|
||||
break;
|
||||
}
|
||||
$response .= $line;
|
||||
|
||||
// La respuesta termina cuando llega la línea tagged
|
||||
if (str_starts_with($line, $tag)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function readLine(): string
|
||||
{
|
||||
if (! $this->socket) {
|
||||
return '';
|
||||
}
|
||||
return (string) fgets($this->socket, 1024);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
private function decodeMimeHeader(string $value): string
|
||||
{
|
||||
if (function_exists('iconv_mime_decode')) {
|
||||
return iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8');
|
||||
}
|
||||
// Fallback: decodificar manualmente =?charset?encoding?text?=
|
||||
return preg_replace_callback(
|
||||
'/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/u',
|
||||
function ($m) {
|
||||
$charset = $m[1];
|
||||
$encoding = strtoupper($m[2]);
|
||||
$text = $m[3];
|
||||
$decoded = $encoding === 'B' ? base64_decode($text) : quoted_printable_decode(str_replace('_', ' ', $text));
|
||||
return mb_convert_encoding($decoded, 'UTF-8', $charset);
|
||||
},
|
||||
$value
|
||||
);
|
||||
}
|
||||
|
||||
private function cleanBody(string $body): string
|
||||
{
|
||||
// Quitar quoted-printable si aplica
|
||||
if (str_contains($body, '=\r\n') || preg_match('/=[0-9A-F]{2}/i', $body)) {
|
||||
$body = quoted_printable_decode($body);
|
||||
}
|
||||
|
||||
// Quitar líneas de separadores de partes MIME
|
||||
$body = preg_replace('/--[^\r\n]+\r?\n/m', '', $body);
|
||||
|
||||
// Eliminar líneas de cabecera de parte MIME (Content-Type, etc.)
|
||||
$body = preg_replace('/^(Content-[^\r\n]+\r?\n)+/mi', '', $body);
|
||||
|
||||
// Normalizar saltos de línea
|
||||
$body = str_replace(["\r\n", "\r"], "\n", $body);
|
||||
|
||||
// Recortar líneas en blanco excesivas
|
||||
$body = preg_replace('/\n{3,}/', "\n\n", $body);
|
||||
|
||||
return trim($body);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user