CluPilotCloud/app/Services/Mail/ImapMailbox.php

274 lines
8.2 KiB
PHP

<?php
namespace App\Services\Mail;
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 through config, which SecretVault overrides at
* boot. 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(config('services.inbound_mail.password'));
}
/** @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((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<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).'"';
}
}