116 lines
5.7 KiB
PHP
116 lines
5.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\MailFlow;
|
|
|
|
use App\Jobs\SendMailMessageJob;
|
|
use App\Models\MailMessage;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class ImapInbox
|
|
{
|
|
public function __construct(private AutoReplyService $replies, private IncomingContent $content) {}
|
|
|
|
public function poll(): int
|
|
{
|
|
if (!function_exists('imap_open')) {
|
|
throw new \RuntimeException('php_imap_extension_required');
|
|
}
|
|
$host = (string) Settings::get('imap_host');
|
|
$folder = (string) Settings::get('imap_folder', 'INBOX');
|
|
$port = (int) Settings::get('imap_port', 993);
|
|
if (!preg_match('/^[a-zA-Z0-9.-]+$/', $host) || !preg_match('/^[a-zA-Z0-9_ .\/-]+$/', $folder) || $port < 1 || $port > 65535) {
|
|
throw new \RuntimeException('imap_configuration_invalid');
|
|
}
|
|
foreach ([IMAP_OPENTIMEOUT, IMAP_READTIMEOUT, IMAP_WRITETIMEOUT, IMAP_CLOSETIMEOUT] as $timeout) {
|
|
imap_timeout($timeout, 8);
|
|
}
|
|
// TLS with certificate validation; never accept /novalidate-cert.
|
|
$mailbox = '{' . $host . ':' . $port . '/imap/ssl}' . $folder;
|
|
$connection = @imap_open($mailbox, (string) Settings::get('imap_username'), (string) Settings::get('imap_password'), OP_READONLY, 1);
|
|
if (!$connection) {
|
|
imap_errors();
|
|
throw new \RuntimeException('imap_connection_failed');
|
|
}
|
|
try {
|
|
$status = imap_status($connection, $mailbox, SA_UIDVALIDITY | SA_UIDNEXT);
|
|
if (!$status) {
|
|
throw new \RuntimeException('imap_status_failed');
|
|
}
|
|
$identity = hash('sha256', $mailbox . ':' . Settings::get('imap_username'));
|
|
$cursorKey = 'mail_flow_imap_' . $identity;
|
|
$cursor = admin_setting($cursorKey);
|
|
if (!$cursor || ($cursor['validity'] ?? null) !== $status->uidvalidity) {
|
|
// First activation / UIDVALIDITY change: skip historical mail to avoid a reply flood.
|
|
admin_setting([$cursorKey => ['validity' => $status->uidvalidity, 'uid' => $status->uidnext - 1]]);
|
|
return 0;
|
|
}
|
|
$last = (int) $cursor['uid'];
|
|
if ($status->uidnext <= $last + 1) {
|
|
return 0;
|
|
}
|
|
// PHP's IMAP search parser does not support the UID search criterion.
|
|
// Fetch a bounded UID range instead, without downloading message bodies.
|
|
$upper = min($status->uidnext - 1, $last + 100);
|
|
$overview = imap_fetch_overview($connection, ($last + 1) . ':' . $upper, FT_UID);
|
|
if ($overview === false) {
|
|
throw new \RuntimeException('imap_overview_failed');
|
|
}
|
|
$uids = array_map(static fn ($item) => (int) $item->uid, $overview);
|
|
sort($uids, SORT_NUMERIC);
|
|
$count = 0;
|
|
foreach (array_slice($uids, 0, 100) as $uid) {
|
|
if ($uid <= $last) {
|
|
continue;
|
|
}
|
|
$raw = imap_fetchheader($connection, $uid, FT_UID);
|
|
if ($raw === false) {
|
|
throw new \RuntimeException('imap_header_failed');
|
|
}
|
|
$parsed = imap_rfc822_parse_headers($raw);
|
|
$from = $parsed->from[0] ?? null;
|
|
$sender = $from && isset($from->mailbox, $from->host) ? $from->mailbox . '@' . $from->host : '';
|
|
$headers = $this->headers($raw);
|
|
$structure = imap_fetchstructure($connection, $uid, FT_UID);
|
|
$content = $structure ? $this->content->read($connection, $uid, $structure) : ['text' => '', 'dsn' => ''];
|
|
$this->content->recordBounce($content['dsn'], $headers);
|
|
$subject = isset($parsed->subject) ? mb_decode_mimeheader($parsed->subject) : '';
|
|
$message = DB::transaction(function () use ($identity, $status, $uid, $sender, $subject, $headers, $content) {
|
|
$message = MailMessage::firstOrCreate(['message_key' => 'imap:' . hash('sha256', $identity . ':' . $status->uidvalidity . ':' . $uid)], [
|
|
'direction' => 'in', 'purpose' => 'incoming', 'channel' => 'conventional',
|
|
'email' => mb_substr($sender, 0, 254), 'subject' => mb_substr($subject, 0, 998),
|
|
'provider_id' => isset($headers['message-id']) ? mb_substr($headers['message-id'], 0, 250) : null, 'status' => 'received',
|
|
'payload' => ['text' => $content['text']],
|
|
]);
|
|
$reply = $content['dsn'] === '' ? $this->replies->prepare($message, $headers) : null;
|
|
return $reply;
|
|
}, 3);
|
|
// The durable outgoing row is recoverable even if queue dispatch fails.
|
|
if ($message) {
|
|
SendMailMessageJob::dispatch($message->id);
|
|
}
|
|
admin_setting([$cursorKey => ['validity' => $status->uidvalidity, 'uid' => $uid]]);
|
|
$count++;
|
|
}
|
|
// Deleted UID gaps must not stall subsequent synchronization.
|
|
admin_setting([$cursorKey => ['validity' => $status->uidvalidity, 'uid' => $upper]]);
|
|
return $count;
|
|
} finally {
|
|
imap_close($connection);
|
|
imap_errors();
|
|
}
|
|
}
|
|
|
|
private function headers(string $raw): array
|
|
{
|
|
$result = [];
|
|
$raw = preg_replace('/\r?\n[ \t]+/', ' ', $raw);
|
|
foreach (preg_split('/\r?\n/', $raw) as $line) {
|
|
if (str_contains($line, ':')) {
|
|
[$key, $value] = explode(':', $line, 2);
|
|
$result[strtolower(trim($key))] = trim($value);
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
}
|