*/ public function fetch(int $limit = 50): array { if (! $this->isConfigured()) { return []; } try { $this->connect(); $uids = $this->search(); $mails = []; foreach (array_slice($uids, 0, $limit) as $uid) { $raw = $this->fetchRaw($uid); if ($raw === null) { continue; } try { $mails[] = MailParser::parse($uid, $raw); } catch (Throwable $e) { // Skipped, never guessed at: a garbled question in front of // an operator is worse than one they go and read in the // mailbox, where it still is. Log::warning('Could not parse an inbound mail', ['uid' => $uid, 'exception' => $e]); } } return $mails; } catch (Throwable $e) { // A mailbox that cannot be reached is not an outage of this // application. The console still shows every mail already fetched; // the next run tries again. Log::error('Could not read the support mailbox', ['exception' => $e]); return []; } finally { $this->disconnect(); } } public function markSeen(string $uid): void { if (! $this->isConfigured()) { return; } try { $this->connect(); $this->command('UID STORE '.$uid.' +FLAGS (\\Seen)'); } catch (Throwable $e) { // Worth logging and not worth failing over: the ingest deduplicates // on Message-ID, so a flag that did not stick costs one re-read, // not a duplicate row. Log::warning('Could not flag an inbound mail as seen', ['uid' => $uid, 'exception' => $e]); } finally { $this->disconnect(); } } // ---- the socket ---- private function connect(): void { if ($this->socket !== null) { return; } $mailbox = ProvisioningSettings::inboundMailbox(); $host = $mailbox['host']; $port = $mailbox['port']; // 993 is implicit TLS. Anything else is assumed to be plain, which the // configuration documents as the discouraged case. $endpoint = ($port === 993 ? 'ssl://' : 'tcp://').$host.':'.$port; $socket = @stream_socket_client($endpoint, $errno, $error, 15); if ($socket === false) { throw new RuntimeException("Could not reach {$host}:{$port} — {$error}"); } stream_set_timeout($socket, 20); $this->socket = $socket; $this->readLine(); // the server's greeting $this->command(sprintf( 'LOGIN %s %s', $this->quote($mailbox['username']), $this->quote((string) config('services.inbound_mail.password')), )); $this->command('SELECT '.$this->quote($mailbox['folder'])); } private function disconnect(): void { if ($this->socket === null) { return; } try { $this->command('LOGOUT'); } catch (Throwable) { // Closing politely is a courtesy, not a requirement. } fclose($this->socket); $this->socket = null; } /** UIDs of everything unread, oldest first. */ private function search(): array { $lines = $this->command('UID SEARCH UNSEEN'); foreach ($lines as $line) { if (str_starts_with($line, '* SEARCH')) { $uids = preg_split('/\s+/', trim(substr($line, 8))) ?: []; return array_values(array_filter($uids, fn ($uid) => $uid !== '' && ctype_digit($uid))); } } return []; } /** * The message itself, capped. * * PEEK, so reading it does not mark it seen — that is the ingest's decision * to make once the row is safely written, not a side effect of looking. */ private function fetchRaw(string $uid): ?string { $max = (int) config('services.inbound_mail.max_bytes', 262144); $lines = $this->command('UID FETCH '.$uid.' (BODY.PEEK[]<0.'.$max.'>)'); $body = []; $collecting = false; foreach ($lines as $line) { if (! $collecting && preg_match('/\{\d+\}$/', $line) === 1) { $collecting = true; continue; } if ($collecting) { $body[] = $line; } } $raw = implode("\r\n", $body); return trim($raw) === '' ? null : $raw; } // ---- the protocol ---- /** * Send one command and read until its tagged response. * * @return array */ private function command(string $command): array { if ($this->socket === null) { throw new RuntimeException('Not connected.'); } $tag = sprintf('A%03d', ++$this->sequence); fwrite($this->socket, $tag.' '.$command."\r\n"); $lines = []; while (true) { $line = $this->readLine(); if ($line === null) { throw new RuntimeException('The server stopped answering.'); } if (str_starts_with($line, $tag.' ')) { if (! str_starts_with($line, $tag.' OK')) { // Never with the command in the message: LOGIN carries the // password, and an exception is written to a log file. throw new RuntimeException('IMAP refused a command: '.substr($line, strlen($tag) + 1)); } return $lines; } $lines[] = $line; } } private function readLine(): ?string { if ($this->socket === null) { return null; } $line = fgets($this->socket); return $line === false ? null : rtrim($line, "\r\n"); } /** IMAP quoting: a literal string in double quotes, backslash-escaped. */ private function quote(string $value): string { return '"'.str_replace(['\\', '"'], ['\\\\', '\\"'], $value).'"'; } }