274 lines
8.2 KiB
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).'"';
|
|
}
|
|
}
|