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