CluPilotCloud/app/Services/Mail/ImapMailbox.php

342 lines
11 KiB
PHP

<?php
namespace App\Services\Mail;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* IMAP over a TLS socket, by hand.
*
* ## Why by hand
*
* Because the alternative is a dependency, and this needs four IMAP verbs:
* LOGIN, SELECT, SEARCH UNSEEN, FETCH. The parsing that follows is the real
* work either way, and it is bounded here by what the console actually shows: a
* sender, a subject, the text of the message, and the NAMES of any attachments.
*
* ## What it deliberately does not do
*
* It does not download attachments — only the part headers are read, so a name
* and a size are known and the file is left on the server. It does not render
* HTML mail: where a message has both, the text part wins, and where it has
* only HTML the tags are stripped. It does not delete anything.
*
* A message it cannot parse is skipped and logged, never guessed at. A garbled
* question in front of an operator is worse than a missing one they go and read
* in the mailbox — the original is still there either way.
*
* ## The connection
*
* IMAPS on 993 by default. Plain 143 is possible but not the default and not
* recommended: this carries a password and the customers' own words.
*/
class ImapMailbox implements InboundMailbox
{
/** @var resource|null */
private $socket = null;
private int $sequence = 0;
/**
* Configured means: somebody entered a host, a user and a password.
*
* The first two come from the console (ProvisioningSettings), the password
* from the VAULT — read here, at the point of use. It used to read
* config('services.inbound_mail.password'), which is only ever the .env
* value: this project deliberately does not overlay stored secrets onto
* config at boot (SecretVault's docblock, rule 3). So a password saved in
* the console counted for nothing, isConfigured() stayed false, and the
* inbox went on saying no mailbox was set up while the password sat right
* there on the settings page. Reported exactly that way.
*
* SecretVault::get() falls back to the configured value when nothing is
* stored, so an installation carrying INBOUND_MAIL_PASSWORD in .env keeps
* working unchanged.
*
* No separate "enabled" switch to forget: a mailbox with credentials IS the
* switch, and one without them cannot be polled anyway.
*/
public function isConfigured(): bool
{
$mailbox = ProvisioningSettings::inboundMailbox();
return filled($mailbox['host'])
&& filled($mailbox['username'])
&& filled($this->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<int, FetchedMail> */
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<int, string>
*/
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).'"';
}
}