password()); } /** The mailbox password, from the vault, falling back to .env. */ private function password(): string { return (string) app(SecretVault::class)->get('inbound_mail.password'); } /** * Reach the mailbox and count what is waiting. * * Deliberately the same four verbs the fetch uses — LOGIN, SELECT, SEARCH — * so a green check means the thing that actually runs would work. A check * that proves something easier than the real job is worse than none: it was * exactly that mistake (a test computing its expectation with the code under * test) the timezone rule was written about. * * @return array{ok: bool, message: string, unseen: int|null} */ public function check(): array { if (! $this->isConfigured()) { return ['ok' => false, 'message' => 'incomplete', 'unseen' => null]; } try { $this->connect(); $unseen = count($this->search()); return ['ok' => true, 'message' => '', 'unseen' => $unseen]; } catch (Throwable $e) { // The message, not the trace, and never the command: LOGIN carries // the password and this string is shown on a page. return ['ok' => false, 'message' => $this->reason($e), 'unseen' => null]; } finally { $this->disconnect(); } } /** * One line an operator can act on, with nothing secret in it. * * IMAP servers answer a failed LOGIN with their own prose, and this class * puts the command in the exception message — which for LOGIN would be the * password. So the text is matched, never passed through. */ private function reason(Throwable $e): string { $text = strtolower($e->getMessage()); return match (true) { str_contains($text, 'could not reach') => 'unreachable', str_contains($text, 'authenticationfailed') || str_contains($text, 'login') => 'credentials', str_contains($text, 'nonexistent') || str_contains($text, 'select') => 'folder', default => 'failed', }; } /** @return array */ 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($this->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).'"'; } }