diff --git a/VERSION b/VERSION index 49e16b6..4f9de89 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.34 +1.3.35 diff --git a/app/Console/Commands/FetchInboundMail.php b/app/Console/Commands/FetchInboundMail.php new file mode 100644 index 0000000..720f828 --- /dev/null +++ b/app/Console/Commands/FetchInboundMail.php @@ -0,0 +1,29 @@ +option('limit')); + + $this->info($taken === 0 ? 'Nothing new.' : $taken.' new message(s).'); + + return self::SUCCESS; + } +} diff --git a/app/Livewire/Admin/Inbox.php b/app/Livewire/Admin/Inbox.php new file mode 100644 index 0000000..0204def --- /dev/null +++ b/app/Livewire/Admin/Inbox.php @@ -0,0 +1,132 @@ +authorize('customers.manage'); + } + + /** + * Look now rather than at the next tick. + * + * The scheduler polls every couple of minutes; this is for the operator on + * the phone with somebody who says "ich habe es gerade geschickt". + */ + public function fetchNow(IngestInboundMail $ingest): void + { + $this->authorize('customers.manage'); + + $taken = $ingest(); + + $this->dispatch('notify', message: $taken === 0 + ? __('inbox.nothing_new') + : trans_choice('inbox.fetched', $taken, ['count' => $taken])); + } + + /** Put one away. It stays in the mailbox; only this list forgets it. */ + public function archive(string $uuid): void + { + $this->authorize('customers.manage'); + + InboundMail::query()->where('uuid', $uuid)->update([ + 'archived_at' => now(), + 'read_at' => now(), + ]); + } + + public function unarchive(string $uuid): void + { + $this->authorize('customers.manage'); + + InboundMail::query()->where('uuid', $uuid)->update(['archived_at' => null]); + } + + /** + * Attach a mail nobody could place to a customer. + * + * The address decides automatically and this is the manual override: a + * customer who writes from their private account is one an operator + * recognises and the matcher never will. + */ + public function assign(string $uuid, string $customerUuid): void + { + $this->authorize('customers.manage'); + + $mail = InboundMail::query()->where('uuid', $uuid)->first(); + + if ($mail === null) { + return; + } + + if ($customerUuid === '') { + $mail->update(['customer_id' => null]); + + return; + } + + $customer = Customer::query()->where('uuid', $customerUuid)->first(); + + if ($customer !== null) { + $mail->update(['customer_id' => $customer->id]); + $this->dispatch('notify', message: __('inbox.assigned', ['name' => $customer->name])); + } + } + + public function render() + { + $this->authorize('customers.manage'); + + $mails = InboundMail::query() + ->with('customer') + ->when(! $this->archived, fn ($q) => $q->open()) + ->when($this->archived, fn ($q) => $q->whereNotNull('archived_at')) + // Unassigned first: the mail nobody has placed is the one that gets + // forgotten, and sorting it under the rest is how it stays that way. + ->orderByRaw('customer_id IS NULL DESC') + ->latest('received_at') + ->limit(100) + ->get(); + + return view('livewire.admin.inbox', [ + 'mails' => $mails, + 'customers' => Customer::query()->whereNull('closed_at')->orderBy('name')->get(['uuid', 'name', 'email']), + 'openCount' => InboundMail::query()->open()->count(), + // Said on the page rather than left as an empty list nobody can + // explain: a mailbox that was never configured looks exactly like a + // mailbox with no mail in it. + 'configured' => app(InboundMailbox::class)->isConfigured(), + ]); + } +} diff --git a/app/Livewire/Admin/Integrations.php b/app/Livewire/Admin/Integrations.php index 9af9d84..a9fbcea 100644 --- a/app/Livewire/Admin/Integrations.php +++ b/app/Livewire/Admin/Integrations.php @@ -102,6 +102,21 @@ class Integrations extends Component public string $monitoringUrl = ''; + /** + * The support mailbox this installation reads customer mail from. + * + * Host, user and folder only. The PASSWORD is a vault entry — it opens + * something, and this page's rule is that anything which opens something is + * write-only and behind a confirmed password. + */ + public string $inboundHost = ''; + + public string $inboundPort = '993'; + + public string $inboundUsername = ''; + + public string $inboundFolder = 'INBOX'; + // .env editor. public string $envContent = ''; @@ -154,6 +169,12 @@ class Integrations extends Component $this->traefikDynamicPath = ProvisioningSettings::traefikDynamicPath(); $this->sshPublicKey = ProvisioningSettings::sshPublicKey(); $this->monitoringUrl = ProvisioningSettings::monitoringUrl(); + + $mailbox = ProvisioningSettings::inboundMailbox(); + $this->inboundHost = $mailbox['host']; + $this->inboundPort = (string) $mailbox['port']; + $this->inboundUsername = $mailbox['username']; + $this->inboundFolder = $mailbox['folder']; } } @@ -170,6 +191,10 @@ class Integrations extends Component 'traefikDynamicPath' => ['nullable', 'string', 'max:255'], 'sshPublicKey' => ['nullable', 'string', 'max:1000'], 'monitoringUrl' => ['nullable', 'url', 'max:255'], + 'inboundHost' => ['nullable', 'string', 'max:255'], + 'inboundPort' => ['required', 'integer', 'min:1', 'max:65535'], + 'inboundUsername' => ['nullable', 'string', 'max:255'], + 'inboundFolder' => ['required', 'string', 'max:255'], ]); Settings::set('provisioning.dns_zone', trim((string) $data['dnsZone'])); @@ -178,6 +203,10 @@ class Integrations extends Component Settings::set('provisioning.traefik_dynamic_path', trim((string) $data['traefikDynamicPath'])); Settings::set('provisioning.ssh_public_key', trim((string) $data['sshPublicKey'])); Settings::set('monitoring.api_url', trim((string) $data['monitoringUrl'])); + Settings::set('inbound_mail.host', trim((string) $data['inboundHost'])); + Settings::set('inbound_mail.port', (int) $data['inboundPort']); + Settings::set('inbound_mail.username', trim((string) $data['inboundUsername'])); + Settings::set('inbound_mail.folder', trim((string) $data['inboundFolder'])); $this->dispatch('notify', message: __('integrations.settings_saved')); } diff --git a/app/Models/InboundMail.php b/app/Models/InboundMail.php new file mode 100644 index 0000000..6146c6a --- /dev/null +++ b/app/Models/InboundMail.php @@ -0,0 +1,71 @@ + 'array', + 'received_at' => 'datetime', + 'read_at' => 'datetime', + 'archived_at' => 'datetime', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function supportRequest(): BelongsTo + { + return $this->belongsTo(SupportRequest::class); + } + + /** Still in the inbox, i.e. not put away. */ + public function scopeOpen(Builder $query): Builder + { + return $query->whereNull('archived_at'); + } + + /** + * From an address nobody recognises. + * + * Not an error state: it is a new enquiry, or a customer writing from their + * private address. The mail an operator must not miss is exactly this one. + */ + public function isUnassigned(): bool + { + return $this->customer_id === null; + } + + public function hasAttachments(): bool + { + return ! empty($this->attachments); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f1ee8d1..1c25740 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,6 +7,8 @@ use App\Http\Middleware\EnsureCustomerActive; use App\Http\Middleware\RestrictAdminHost; use App\Http\Middleware\RestrictConsoleNetwork; use App\Listeners\RecordSentMail; +use App\Services\Mail\ImapMailbox; +use App\Services\Mail\InboundMailbox; use App\Listeners\RecordSignInDevice; use App\Mail\MaintenanceCancelledMail; use App\Mail\Transport\MailboxTransport; @@ -62,6 +64,11 @@ class AppServiceProvider extends ServiceProvider $this->app->bind(TraefikWriter::class, SshTraefikWriter::class); $this->app->bind(MonitoringClient::class, HttpMonitoringClient::class); $this->app->bind(StripeClient::class, HttpStripeClient::class); + // The support mailbox, read over IMAP. A test puts a FakeMailbox here: + // the socket half cannot be exercised without a mail server, and a test + // that needs one is a test nobody runs. What IS exercised is the + // parsing and every decision the ingest makes afterwards. + $this->app->bind(InboundMailbox::class, ImapMailbox::class); } /** diff --git a/app/Services/Mail/FakeMailbox.php b/app/Services/Mail/FakeMailbox.php new file mode 100644 index 0000000..05b1500 --- /dev/null +++ b/app/Services/Mail/FakeMailbox.php @@ -0,0 +1,37 @@ + */ + public array $mails = []; + + /** @var array */ + public array $seen = []; + + public function isConfigured(): bool + { + return $this->configured; + } + + public function fetch(int $limit = 50): array + { + return array_slice($this->mails, 0, $limit); + } + + public function markSeen(string $uid): void + { + $this->seen[] = $uid; + } +} diff --git a/app/Services/Mail/FetchedMail.php b/app/Services/Mail/FetchedMail.php new file mode 100644 index 0000000..0f24c15 --- /dev/null +++ b/app/Services/Mail/FetchedMail.php @@ -0,0 +1,29 @@ + $attachments names and sizes only — never files + */ + public function __construct( + public string $uid, + public string $messageId, + public string $fromEmail, + public ?string $fromName, + public string $subject, + public string $body, + public Carbon $receivedAt, + public array $attachments = [], + ) {} +} diff --git a/app/Services/Mail/ImapMailbox.php b/app/Services/Mail/ImapMailbox.php new file mode 100644 index 0000000..c7f1cea --- /dev/null +++ b/app/Services/Mail/ImapMailbox.php @@ -0,0 +1,273 @@ + */ + 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 + */ + 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).'"'; + } +} diff --git a/app/Services/Mail/InboundMailbox.php b/app/Services/Mail/InboundMailbox.php new file mode 100644 index 0000000..3e240fb --- /dev/null +++ b/app/Services/Mail/InboundMailbox.php @@ -0,0 +1,35 @@ + + */ + public function fetch(int $limit = 50): array; + + /** + * Mark a message as seen on the SERVER, so the next run skips it. + * + * Seen, never deleted. The copy on the mail server is not ours to remove — + * an application that empties somebody's mailbox is one nobody hands a + * password to, and the original is the only place an attachment still + * exists once this application has refused to store it. + */ + public function markSeen(string $uid): void; +} diff --git a/app/Services/Mail/IngestInboundMail.php b/app/Services/Mail/IngestInboundMail.php new file mode 100644 index 0000000..15b5fc1 --- /dev/null +++ b/app/Services/Mail/IngestInboundMail.php @@ -0,0 +1,103 @@ +mailbox->isConfigured()) { + return 0; + } + + $taken = 0; + + foreach ($this->mailbox->fetch($limit) as $mail) { + $stored = $this->store($mail); + + if ($stored) { + $taken++; + } + + // Flagged only once the row is safely written — including for a + // duplicate, which IS safely written, just earlier. Flagging first + // and failing after would lose the message with nothing to show + // for it, and there is no second copy anywhere. + $this->mailbox->markSeen($mail->uid); + } + + return $taken; + } + + private function store(FetchedMail $mail): bool + { + $customer = Customer::query()->where('email', $mail->fromEmail)->first(); + + try { + InboundMail::create([ + 'customer_id' => $customer?->id, + 'support_request_id' => $customer === null ? null : $this->openRequestFor($customer)?->id, + 'message_id' => $mail->messageId, + 'from_email' => $mail->fromEmail, + 'from_name' => $mail->fromName, + 'subject' => $mail->subject, + 'body' => $mail->body, + 'attachments' => $mail->attachments === [] ? null : $mail->attachments, + 'received_at' => $mail->receivedAt, + ]); + } catch (UniqueConstraintViolationException) { + // Already here. The fetch runs every few minutes and a flag that + // did not stick on the server costs one re-read — which is exactly + // what this catch turns into nothing. + return false; + } catch (\Throwable $e) { + Log::error('Could not store an inbound mail', ['exception' => $e]); + + return false; + } + + return true; + } + + /** + * The question this is probably an answer to. + * + * The customer's most recent request that is still open. A guess, and a + * shallow one on purpose — it only pre-fills a link an operator can undo, + * and threading by References/In-Reply-To would need the whole outgoing + * side to carry stable ids first. + */ + private function openRequestFor(Customer $customer): ?SupportRequest + { + return SupportRequest::query() + ->where('customer_id', $customer->id) + ->whereNotIn('status', SupportRequest::CLOSED) + ->latest('id') + ->first(); + } +} diff --git a/app/Services/Mail/MailParser.php b/app/Services/Mail/MailParser.php new file mode 100644 index 0000000..7da315a --- /dev/null +++ b/app/Services/Mail/MailParser.php @@ -0,0 +1,303 @@ + + */ + private static function headers(string $block): array + { + // Unfold first: a long Subject is legally split across lines with the + // continuation indented, and reading it line by line would cut it. + $unfolded = preg_replace('/\r?\n[ \t]+/', ' ', $block) ?? $block; + $headers = []; + + foreach (preg_split('/\r?\n/', $unfolded) ?: [] as $line) { + $colon = strpos($line, ':'); + + if ($colon === false) { + continue; + } + + $name = strtolower(trim(substr($line, 0, $colon))); + + // First wins. A message with two Subject headers is malformed, and + // the second one is the interesting one to an attacker. + if (! array_key_exists($name, $headers)) { + $headers[$name] = trim(substr($line, $colon + 1)); + } + } + + return $headers; + } + + /** @return array{email: string, name: ?string} */ + private static function address(string $value): array + { + $value = self::decodeHeader($value); + + if (preg_match('/<([^>]+)>/', $value, $m) === 1) { + $name = trim(str_replace('"', '', substr($value, 0, strpos($value, '<')))); + + return ['email' => strtolower(trim($m[1])), 'name' => $name === '' ? null : $name]; + } + + return ['email' => strtolower(trim($value)), 'name' => null]; + } + + /** RFC 2047: `=?charset?B|Q?text?=`, possibly several times in one line. */ + private static function decodeHeader(string $value): string + { + $decoded = preg_replace_callback( + '/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/', + function (array $m): string { + $text = strtoupper($m[2]) === 'B' + ? (base64_decode($m[3], true) ?: '') + // Underscore is a space in Q encoding — the one way it + // differs from quoted-printable, and the reason a decoded + // subject otherwise reads "Ihre_Frage". + : quoted_printable_decode(str_replace('_', ' ', $m[3])); + + return self::toUtf8($text, $m[1]); + }, + $value, + ); + + return trim($decoded ?? $value); + } + + /** + * The readable text, and what was attached. + * + * @param array $headers + * @return array{text: string, attachments: array} + */ + private static function parts(array $headers, string $body, int $depth = 0): array + { + $contentType = strtolower($headers['content-type'] ?? 'text/plain'); + + if (str_starts_with($contentType, 'multipart/') && $depth < self::MAX_DEPTH) { + return self::multipart($headers, $body, $contentType, $depth); + } + + // An attachment at the top level: a bare file mailed on its own. + $name = self::attachmentName($headers); + + if ($name !== null) { + return ['text' => '', 'attachments' => [['name' => $name, 'bytes' => strlen($body)]]]; + } + + return ['text' => self::text($headers, $body, $contentType), 'attachments' => []]; + } + + /** + * @param array $headers + * @return array{text: string, attachments: array} + */ + private static function multipart(array $headers, string $body, string $contentType, int $depth): array + { + if (preg_match('/boundary="?([^";]+)"?/i', $headers['content-type'] ?? '', $m) !== 1) { + throw new RuntimeException('A multipart message with no boundary.'); + } + + $chunks = explode('--'.$m[1], $body); + $text = ''; + $html = ''; + $attachments = []; + + foreach ($chunks as $chunk) { + $chunk = ltrim($chunk, "\r\n"); + + if ($chunk === '' || str_starts_with($chunk, '--')) { + continue; // the preamble and the closing delimiter + } + + try { + [$partHeaderBlock, $partBody] = self::split($chunk); + } catch (RuntimeException) { + continue; + } + + $partHeaders = self::headers($partHeaderBlock); + $partType = strtolower($partHeaders['content-type'] ?? 'text/plain'); + $name = self::attachmentName($partHeaders); + + if ($name !== null) { + // The name and the size, and then we stop reading. Keeping the + // file itself would make this application a store of whatever + // strangers choose to send it. + $attachments[] = ['name' => $name, 'bytes' => strlen($partBody)]; + + continue; + } + + if (str_starts_with($partType, 'multipart/')) { + $nested = self::parts($partHeaders, $partBody, $depth + 1); + $text = $text !== '' ? $text : $nested['text']; + $attachments = array_merge($attachments, $nested['attachments']); + + continue; + } + + $decoded = self::text($partHeaders, $partBody, $partType); + + if (str_starts_with($partType, 'text/plain') && $text === '') { + $text = $decoded; + } elseif (str_starts_with($partType, 'text/html') && $html === '') { + $html = $decoded; + } + } + + // Plain wins. A console is not a mail client, and what a person wrote is + // in the text part; the HTML one is the same words wrapped in markup. + return ['text' => $text !== '' ? $text : $html, 'attachments' => $attachments]; + } + + /** @param array $headers */ + private static function attachmentName(array $headers): ?string + { + $disposition = $headers['content-disposition'] ?? ''; + + if (preg_match('/filename="?([^";]+)"?/i', $disposition, $m) === 1) { + return self::decodeHeader($m[1]); + } + + // Some senders put the name only on the type, with no disposition. + if (preg_match('/name="?([^";]+)"?/i', $headers['content-type'] ?? '', $m) === 1) { + return self::decodeHeader($m[1]); + } + + return str_starts_with(strtolower($disposition), 'attachment') ? 'unbenannt' : null; + } + + /** @param array $headers */ + private static function text(array $headers, string $body, string $contentType): string + { + $encoding = strtolower(trim($headers['content-transfer-encoding'] ?? '7bit')); + + $decoded = match ($encoding) { + 'base64' => base64_decode(preg_replace('/\s+/', '', $body) ?? '', true) ?: '', + 'quoted-printable' => quoted_printable_decode($body), + default => $body, + }; + + $charset = preg_match('/charset="?([^";]+)"?/i', $contentType, $m) === 1 ? $m[1] : 'UTF-8'; + $decoded = self::toUtf8($decoded, $charset); + + if (str_starts_with($contentType, 'text/html')) { + // Not a renderer: the tags come out, the words stay. The console + // shows this as text and escapes it on the way to the page. + $decoded = html_entity_decode(strip_tags($decoded), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + return trim($decoded); + } + + private static function toUtf8(string $text, string $charset): string + { + $charset = strtoupper(trim($charset)); + + if ($charset === '' || $charset === 'UTF-8' || $charset === 'US-ASCII') { + return $text; + } + + $converted = @mb_convert_encoding($text, 'UTF-8', $charset); + + return $converted === false ? $text : $converted; + } + + private static function date(?string $value): Carbon + { + if ($value === null || trim($value) === '') { + return Carbon::now(); + } + + try { + return Carbon::parse($value); + } catch (\Throwable) { + // An unreadable Date header is not a reason to lose the message — + // and "when we read it" is closer to the truth than a guess. + return Carbon::now(); + } + } +} diff --git a/app/Services/Secrets/SecretVault.php b/app/Services/Secrets/SecretVault.php index 6dec28c..e6b5590 100644 --- a/app/Services/Secrets/SecretVault.php +++ b/app/Services/Secrets/SecretVault.php @@ -90,6 +90,11 @@ final class SecretVault 'label' => 'secrets.item.monitoring_token', 'env_key' => 'MONITORING_API_TOKEN', ], + 'inbound_mail.password' => [ + 'config' => 'services.inbound_mail.password', + 'label' => 'secrets.item.inbound_mail_password', + 'env_key' => 'INBOUND_MAIL_PASSWORD', + ], 'ssh.private_key' => [ 'config' => 'provisioning.ssh.private_key', 'label' => 'secrets.item.ssh_private_key', diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index eada042..32ff4c0 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -60,9 +60,10 @@ final class Navigation // site-visibility switch invites somebody to change one in passing. ['admin.finance', 'receipt', 'finance', 'site.manage'], ['admin.invoices', 'file-text', 'invoices', 'site.manage'], - // What this installation has sent, and to whom. Under Betrieb - // rather than System: it is read while answering a customer, - // not while configuring the machine. + // What customers wrote to us, and what we sent them. Under + // Betrieb rather than System: both are read while answering + // somebody, not while configuring a machine. + ['admin.inbox', 'mail', 'inbox', 'customers.manage'], ['admin.mail-log', 'send', 'mail_log', 'customers.manage'], ]], ['label' => __('admin.nav_group.system'), 'items' => [ diff --git a/app/Support/ProvisioningSettings.php b/app/Support/ProvisioningSettings.php index e5a0724..6a189b4 100644 --- a/app/Support/ProvisioningSettings.php +++ b/app/Support/ProvisioningSettings.php @@ -60,4 +60,24 @@ final class ProvisioningSettings { return (string) Settings::get('monitoring.api_url', (string) config('services.monitoring.url')); } + + /** + * The support mailbox this installation reads customer mail from. + * + * Host, user and folder are plain settings; the PASSWORD is a vault entry + * (SecretVault 'inbound_mail.password'), like every other credential that + * opens something. Same fall-back shape as the rest: what an operator saved + * wins, and the .env value stands in until they have. + * + * @return array{host: string, port: int, username: string, folder: string} + */ + public static function inboundMailbox(): array + { + return [ + 'host' => (string) Settings::get('inbound_mail.host', (string) config('services.inbound_mail.host')), + 'port' => (int) Settings::get('inbound_mail.port', (int) config('services.inbound_mail.port', 993)), + 'username' => (string) Settings::get('inbound_mail.username', (string) config('services.inbound_mail.username')), + 'folder' => (string) Settings::get('inbound_mail.folder', (string) config('services.inbound_mail.folder', 'INBOX')), + ]; + } } diff --git a/config/services.php b/config/services.php index 7398bfd..84462ab 100644 --- a/config/services.php +++ b/config/services.php @@ -44,6 +44,31 @@ return [ 'api_base' => env('STRIPE_API_BASE', 'https://api.stripe.com/v1'), ], + /* + * The mailbox customer replies land in. + * + * IMAP, not an API token: mailcow's API administers domains and mailboxes — + * it does not read mail. Reading a mailbox is IMAP whoever runs the server, + * and that is what this is. + * + * The password belongs in the vault (SecretVault 'inbound_mail.password'), + * like every other credential that opens something. + */ + 'inbound_mail' => [ + 'enabled' => env('INBOUND_MAIL_ENABLED', false), + 'host' => env('INBOUND_MAIL_HOST'), + // 993 is IMAPS. Plain 143 is deliberately not the default: this + // connection carries a password and the customers' own words. + 'port' => (int) env('INBOUND_MAIL_PORT', 993), + 'username' => env('INBOUND_MAIL_USERNAME'), + 'password' => env('INBOUND_MAIL_PASSWORD'), + 'folder' => env('INBOUND_MAIL_FOLDER', 'INBOX'), + // How much of one message we are willing to read. A mail with a 40 MB + // attachment must not become 40 MB of memory in a queue worker; the + // body we want is in the first few hundred kilobytes. + 'max_bytes' => (int) env('INBOUND_MAIL_MAX_BYTES', 262144), + ], + 'monitoring' => [ 'url' => env('MONITORING_API_URL'), 'token' => env('MONITORING_API_TOKEN'), diff --git a/database/migrations/2026_07_30_200000_read_the_support_mailbox_into_the_console.php b/database/migrations/2026_07_30_200000_read_the_support_mailbox_into_the_console.php new file mode 100644 index 0000000..6804443 --- /dev/null +++ b/database/migrations/2026_07_30_200000_read_the_support_mailbox_into_the_console.php @@ -0,0 +1,71 @@ +id(); + $table->uuid()->unique(); + + // Who it belongs to, once we know. Null is the working state for + // anything from an address we do not recognise. + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + // The portal request this mail answers or became, where one exists. + $table->foreignId('support_request_id')->nullable()->constrained()->nullOnDelete(); + + $table->string('message_id')->unique(); + $table->string('from_email')->index(); + $table->string('from_name')->nullable(); + $table->string('subject')->nullable(); + $table->longText('body')->nullable(); + + // Names and sizes only — never the files themselves. + $table->json('attachments')->nullable(); + + $table->timestamp('received_at')->index(); + // Read and archived are the operator's two states. Neither touches + // the mailbox: the copy on the mail server is not ours to change, + // and an application that deletes somebody's mail on their server + // is an application nobody trusts with a password. + $table->timestamp('read_at')->nullable(); + $table->timestamp('archived_at')->nullable(); + $table->timestamps(); + + $table->index(['archived_at', 'received_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('inbound_mails'); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index 9d9284f..7de32a8 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -11,6 +11,7 @@ return [ ], 'nav' => [ + 'inbox' => 'Posteingang', 'mail_log' => 'Postausgang', 'overview' => 'Übersicht', 'customers' => 'Kunden', diff --git a/lang/de/inbox.php b/lang/de/inbox.php new file mode 100644 index 0000000..94f2e58 --- /dev/null +++ b/lang/de/inbox.php @@ -0,0 +1,30 @@ + 'Posteingang', + 'subtitle' => 'E-Mails, die an das Support-Postfach gegangen sind. Wer unter seiner Registrierungsadresse schreibt, wird automatisch zugeordnet — alles andere steht oben und wartet auf Sie.', + + 'fetch_now' => 'Jetzt abrufen', + 'fetched' => '{1} :count neue Nachricht|[2,*] :count neue Nachrichten', + 'nothing_new' => 'Nichts Neues im Postfach.', + + 'show_archived' => 'Erledigte zeigen', + 'show_open' => 'Offene zeigen', + 'empty' => 'Der Posteingang ist leer.', + 'empty_archived' => 'Nichts abgelegt.', + + 'no_subject' => '(kein Betreff)', + 'unassigned' => 'Nicht zugeordnet', + 'assign_to' => 'Kunde zuordnen …', + 'assigned' => 'Zugeordnet: :name', + 'answer' => ':name antworten', + 'archive' => 'Ablegen', + 'unarchive' => 'Zurückholen', + + 'attachments' => 'Anhänge', + 'attachments_note' => 'Anhänge werden bewusst nicht gespeichert — nur Name und Größe. Die Dateien liegen weiterhin im Postfach; öffnen Sie sie dort, wenn Sie ihnen trauen.', + + 'not_configured' => 'Es ist noch kein Postfach hinterlegt. Ohne Zugangsdaten wird nichts abgerufen, und diese Seite bleibt leer, egal wie viel geschrieben wird.', + 'to_integrations' => 'Postfach einrichten', +]; diff --git a/lang/de/integrations.php b/lang/de/integrations.php index c352919..4551151 100644 --- a/lang/de/integrations.php +++ b/lang/de/integrations.php @@ -81,6 +81,14 @@ return [ 'env_invalid' => 'Zeile :line ist weder leer, noch ein Kommentar, noch SCHLÜSSEL=Wert. Nichts wurde gespeichert.', 'env_empty' => 'Eine leere Datei würde jede Einstellung löschen. Nichts wurde gespeichert.', + 'inbound_title' => 'Support-Postfach (Posteingang)', + 'inbound_body' => 'Von hier holt CluPilot Kundenmails ab und zeigt sie unter Betrieb → Posteingang. IMAP, nicht die mailcow-API: die API verwaltet Domains und Postfächer, gelesen wird ein Postfach über IMAP. Das Passwort liegt verschlüsselt im Tresor.', + 'inbound_host' => 'IMAP-Server', + 'inbound_port' => 'Port', + 'inbound_port_hint' => '993 ist IMAP über TLS. Alles andere läuft unverschlüsselt — dieser Zugang trägt ein Passwort und die Worte Ihrer Kunden.', + 'inbound_username' => 'Postfach', + 'inbound_folder' => 'Ordner', + // Die Reiter. Getrennt nach dem, wofür ein Abschnitt da ist — nicht danach, // welcher Speichermechanismus einen Wert zufällig hält. 'tab' => [ diff --git a/lang/de/secrets.php b/lang/de/secrets.php index a6e32f4..234f6c5 100644 --- a/lang/de/secrets.php +++ b/lang/de/secrets.php @@ -4,6 +4,7 @@ return [ // Die Namen der verwalteten Zugangsdaten. 'item' => [ 'stripe_secret' => 'Stripe Secret Key', + 'inbound_mail_password' => 'Passwort des Support-Postfachs', 'dns_token' => 'Hetzner-DNS-API-Token', 'monitoring_token' => 'Uptime-Kuma-API-Token', 'ssh_private_key' => 'SSH-Schlüssel (privat)', diff --git a/lang/en/admin.php b/lang/en/admin.php index 058c2eb..691f953 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -11,6 +11,7 @@ return [ ], 'nav' => [ + 'inbox' => 'Inbox', 'mail_log' => 'Mail sent', 'overview' => 'Overview', 'customers' => 'Customers', diff --git a/lang/en/inbox.php b/lang/en/inbox.php new file mode 100644 index 0000000..01f742a --- /dev/null +++ b/lang/en/inbox.php @@ -0,0 +1,30 @@ + 'Inbox', + 'subtitle' => 'Mail that arrived in the support mailbox. Anybody writing from the address they registered with is matched automatically — everything else sits at the top and waits for you.', + + 'fetch_now' => 'Fetch now', + 'fetched' => '{1} :count new message|[2,*] :count new messages', + 'nothing_new' => 'Nothing new in the mailbox.', + + 'show_archived' => 'Show filed', + 'show_open' => 'Show open', + 'empty' => 'The inbox is empty.', + 'empty_archived' => 'Nothing filed.', + + 'no_subject' => '(no subject)', + 'unassigned' => 'Unassigned', + 'assign_to' => 'Assign a customer …', + 'assigned' => 'Assigned to :name', + 'answer' => 'Answer :name', + 'archive' => 'File', + 'unarchive' => 'Put back', + + 'attachments' => 'Attachments', + 'attachments_note' => 'Attachments are deliberately not stored — only name and size. The files are still in the mailbox; open them there if you trust them.', + + 'not_configured' => 'No mailbox has been set up yet. Without credentials nothing is fetched, and this page stays empty however much is written.', + 'to_integrations' => 'Set up the mailbox', +]; diff --git a/lang/en/integrations.php b/lang/en/integrations.php index dc19341..24ea100 100644 --- a/lang/en/integrations.php +++ b/lang/en/integrations.php @@ -81,6 +81,14 @@ return [ 'env_invalid' => 'Line :line is neither blank, a comment, nor KEY=value. Nothing was saved.', 'env_empty' => 'An empty file would erase every setting. Nothing was saved.', + 'inbound_title' => 'Support mailbox (inbox)', + 'inbound_body' => 'Where CluPilot fetches customer mail from, shown under Operations → Inbox. IMAP, not the mailcow API: the API administers domains and mailboxes, reading one is IMAP. The password is kept encrypted in the vault.', + 'inbound_host' => 'IMAP server', + 'inbound_port' => 'Port', + 'inbound_port_hint' => '993 is IMAP over TLS. Anything else is unencrypted — and this connection carries a password and your customers own words.', + 'inbound_username' => 'Mailbox', + 'inbound_folder' => 'Folder', + // The tabs. Split by what a section is for — not by which storage // mechanism happens to hold a given value. 'tab' => [ diff --git a/lang/en/secrets.php b/lang/en/secrets.php index ddc3e4b..ffe9c07 100644 --- a/lang/en/secrets.php +++ b/lang/en/secrets.php @@ -4,6 +4,7 @@ return [ // Names of the managed credentials. 'item' => [ 'stripe_secret' => 'Stripe secret key', + 'inbound_mail_password' => 'Support mailbox password', 'dns_token' => 'Hetzner DNS API token', 'monitoring_token' => 'Uptime Kuma API token', 'ssh_private_key' => 'SSH key (private)', diff --git a/resources/views/livewire/admin/customer-detail.blade.php b/resources/views/livewire/admin/customer-detail.blade.php index 26ec441..b2792f7 100644 --- a/resources/views/livewire/admin/customer-detail.blade.php +++ b/resources/views/livewire/admin/customer-detail.blade.php @@ -8,7 +8,10 @@

{{ $customer->email }}

- {{ __('customers.status.'.$customer->status) }} + {{-- admin.status.*, not customers.status.*: there is no customers + lang file, and a missing key renders as the key itself — which + is what "customers.status.active" on the page was. --}} + {{ __('admin.status.'.$customer->status) }} @@ -61,7 +64,9 @@
{{ $request->subject }} - {{ __('support.status.'.$request->status) }} + {{-- Flat keys (status_open, …), not a + nested status.* group. --}} + {{ __('support.status_'.$request->status) }} {{ $request->created_at->local()->isoFormat('D. MMM YYYY, HH:mm') }}
diff --git a/resources/views/livewire/admin/inbox.blade.php b/resources/views/livewire/admin/inbox.blade.php new file mode 100644 index 0000000..ea57daf --- /dev/null +++ b/resources/views/livewire/admin/inbox.blade.php @@ -0,0 +1,115 @@ +
+
+
+

{{ __('inbox.title') }}

+

{{ __('inbox.subtitle') }}

+
+
+ + {{ __('inbox.fetch_now') }} + + + {{ $archived ? __('inbox.show_open') : __('inbox.show_archived') }} + +
+
+ + {{-- A mailbox nobody configured looks exactly like a mailbox with no mail + in it. Said out loud, with what is missing. --}} + @if (! $configured) + + {{ __('inbox.not_configured') }} + {{ __('inbox.to_integrations') }} + + @endif + + @if ($mails->isEmpty()) +
+

{{ $archived ? __('inbox.empty_archived') : __('inbox.empty') }}

+
+ @else +
+ @foreach ($mails as $mail) +
$mail->isUnassigned(), + 'border-line' => ! $mail->isUnassigned(), + ])> +
+ {{ $mail->subject ?: __('inbox.no_subject') }} + @if ($mail->isUnassigned()) + {{ __('inbox.unassigned') }} + @endif + {{-- R19: stored in UTC, read on the wall clock. --}} + + {{ $mail->received_at->local()->isoFormat('D. MMM YYYY, HH:mm') }} + +
+ +

+ {{ $mail->from_name ? $mail->from_name.' · ' : '' }}{{ $mail->from_email }} +

+ + {{-- Their own words. Escaped by Blade, printed as text: this + came from a stranger and is never rendered as markup. --}} +

{{ $mail->body }}

+ + @if ($mail->hasAttachments()) + {{-- Names and sizes. The files stay on the mail server — + keeping whatever a stranger attaches would make this + a malware store with a console in front of it. --}} +
+

{{ __('inbox.attachments') }}

+
    + @foreach ($mail->attachments as $file) +
  • + + {{ $file['name'] }} + {{ number_format(($file['bytes'] ?? 0) / 1024, 0, ',', '.') }} KB +
  • + @endforeach +
+

{{ __('inbox.attachments_note') }}

+
+ @endif + +
+ @if ($mail->customer) + {{-- Straight to the page with the answer box on it. --}} + + {{ __('inbox.answer', ['name' => $mail->customer->name]) }} + + @else + {{-- One select, one value, no height change — R20's + exception. The address decides automatically; + this is for the customer writing from their + private account, whom no matcher will ever + recognise and an operator recognises at once. --}} + + @endif + + @if ($mail->archived_at) + + {{ __('inbox.unarchive') }} + + @else + + {{ __('inbox.archive') }} + + @endif +
+
+ @endforeach +
+ @endif +
diff --git a/resources/views/livewire/admin/integrations.blade.php b/resources/views/livewire/admin/integrations.blade.php index 5c52776..2a16057 100644 --- a/resources/views/livewire/admin/integrations.blade.php +++ b/resources/views/livewire/admin/integrations.blade.php @@ -102,6 +102,32 @@ @endif + {{-- Das Support-Postfach — IMAP, nicht die mailcow-API: die API verwaltet + Domains und Postfächer, gelesen wird ein Postfach über IMAP, egal wer + den Server betreibt. --}} + @if ($canSecrets || $canInfra) +
+
+

{{ __('integrations.inbound_title') }}

+

{{ __('integrations.inbound_body') }}

+
+ + @if ($canInfra) +
+ + + + +
+ @endif + + @if ($canSecrets) + @if ($canInfra)
@endif + + @endif +
+ @endif + {{-- Monitoring — token and where the Kuma bridge is reachable. --}} @if ($canSecrets || $canInfra)
diff --git a/routes/admin.php b/routes/admin.php index f88c541..03a4caf 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -43,6 +43,8 @@ Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/finance', Admin\Finance::class)->name('finance'); // One customer, everything about them, and a box to write to them. Route::get('/customers/{uuid}', Admin\CustomerDetail::class)->name('customer'); +// What customers wrote to us — see Admin\Inbox. +Route::get('/inbox', Admin\Inbox::class)->name('inbox'); // The register of what left the building — see Admin\MailLog. Route::get('/mail-log', Admin\MailLog::class)->name('mail-log'); Route::get('/invoices', Admin\Invoices::class)->name('invoices'); diff --git a/routes/console.php b/routes/console.php index bef91a1..25326c9 100644 --- a/routes/console.php +++ b/routes/console.php @@ -58,6 +58,18 @@ Schedule::call(fn () => StripePendingEvent::query() // // Hourly rather than by the minute: the failure it repairs lasts as long as an // outage lasts, and a retry storm against a mount that is down helps nobody. +// The support mailbox. Polling, because IMAP is a mailbox you look in — a mail +// server has no way to tell us something arrived — so the interval IS the +// answer to "how long until I see it". Two minutes is short enough that an +// operator watching the inbox does not reload it by hand, and long enough that +// a mail server is not being asked once a second for nothing. +// +// withoutOverlapping: a slow or unreachable mailbox must not stack runs on top +// of each other until a worker is doing nothing but timing out. +Schedule::command('clupilot:fetch-mail') + ->everyTwoMinutes() + ->withoutOverlapping(); + Schedule::command('clupilot:archive-invoices') ->hourly() ->withoutOverlapping(); diff --git a/tests/Feature/Admin/InboundMailTest.php b/tests/Feature/Admin/InboundMailTest.php new file mode 100644 index 0000000..c25eb86 --- /dev/null +++ b/tests/Feature/Admin/InboundMailTest.php @@ -0,0 +1,334 @@ +mailbox = new FakeMailbox; + app()->instance(InboundMailbox::class, $this->mailbox); +}); + +/** + * A message as a mail server hands it over. + * + * Overrides REPLACE a default header rather than being appended after it. The + * parser keeps the first occurrence of a name on purpose — a message with two + * Subject headers is malformed, and the second one is the interesting one to + * somebody forging mail — so appending would silently test nothing. + */ +function rawMail(string $body = "Guten Tag,\r\n\r\nkönnen Sie unsere Daten übernehmen?\r\n", array $headers = []): string +{ + $lines = [ + 'Return-Path: ', + 'Message-ID: ', + 'From: Kanzlei Berger ', + 'To: support@clupilot.com', + 'Subject: Frage zur Migration', + 'Date: Tue, 29 Jul 2026 09:15:00 +0200', + 'Content-Type: text/plain; charset=UTF-8', + ]; + + foreach ($headers as $override) { + $name = strtolower(strtok($override, ':')); + $lines = array_values(array_filter( + $lines, + fn (string $line) => strtolower(strtok($line, ':')) !== $name, + )); + $lines[] = $override; + } + + return implode("\r\n", $lines)."\r\n\r\n".$body; +} + +/** One fetched message, ready for the ingest. */ +function fetched(string $email = 'kunde@example.test', string $messageId = 'm-1'): FetchedMail +{ + return new FetchedMail( + uid: '1', + messageId: $messageId, + fromEmail: $email, + fromName: 'Kanzlei Berger', + subject: 'Frage zur Migration', + body: 'Können Sie unsere Daten übernehmen?', + receivedAt: now(), + ); +} + +// ---- Reading a real message ---- + +it('reads sender, subject and the words out of an ordinary mail', function () { + $mail = MailParser::parse('7', rawMail()); + + expect($mail->fromEmail)->toBe('kunde@example.test') + ->and($mail->fromName)->toBe('Kanzlei Berger') + ->and($mail->subject)->toBe('Frage zur Migration') + ->and($mail->body)->toContain('können Sie unsere Daten übernehmen?') + ->and($mail->messageId)->toBe(''); +}); + +it('decodes a subject with an umlaut in it, which is most German subjects', function () { + // RFC 2047. Without this the console shows "=?UTF-8?B?…?=" where the + // question should be. + $raw = rawMail(headers: ['Subject: =?UTF-8?B?RsO8ciBTaWU6IEt1bmRlbmFuZnJhZ2U=?=']); + + expect(MailParser::parse('7', $raw)->subject)->toBe('Für Sie: Kundenanfrage'); +}); + +it('decodes a quoted-printable body, and a Windows charset with it', function () { + // What Outlook sends. Stored raw, "können" is a broken character for ever, + // because the column is UTF-8. + $raw = rawMail( + "Guten Tag,\r\n\r\nk=F6nnen Sie das =FCbernehmen?\r\n", + [ + 'Content-Type: text/plain; charset=ISO-8859-1', + 'Content-Transfer-Encoding: quoted-printable', + ], + ); + + expect(MailParser::parse('7', $raw)->body)->toContain('können Sie das übernehmen?'); +}); + +it('takes the text half of a mail that was sent as both text and HTML', function () { + // A console is not a mail client: what the person wrote is in the text + // part, and the HTML one is the same words wrapped in markup. + $raw = implode("\r\n", [ + 'Message-ID: ', + 'From: kunde@example.test', + 'Subject: Zwei Teile', + 'Content-Type: multipart/alternative; boundary="B1"', + ])."\r\n\r\n".implode("\r\n", [ + '--B1', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Der einfache Text.', + '--B1', + 'Content-Type: text/html; charset=UTF-8', + '', + '

Der ausgezeichnete Text.

', + '--B1--', + ]); + + expect(MailParser::parse('7', $raw)->body)->toBe('Der einfache Text.'); +}); + +it('strips the markup when HTML is all there is', function () { + $raw = rawMail('

Bitte um Rückruf.

', ['Content-Type: text/html; charset=UTF-8']); + + expect(MailParser::parse('7', $raw)->body)->toBe('Bitte um Rückruf.'); +}); + +it('names an attachment and refuses to keep it', function () { + // The whole attachment policy in one assertion: the operator learns that + // something was attached and what it was called, and the bytes are not + // stored anywhere. Keeping whatever a stranger sends would make this a + // malware store with a console in front of it. + $raw = implode("\r\n", [ + 'Message-ID: ', + 'From: kunde@example.test', + 'Subject: Mit Anhang', + 'Content-Type: multipart/mixed; boundary="B2"', + ])."\r\n\r\n".implode("\r\n", [ + '--B2', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Anbei die Liste.', + '--B2', + 'Content-Type: application/pdf; name="rechnung.pdf"', + 'Content-Disposition: attachment; filename="rechnung.pdf"', + 'Content-Transfer-Encoding: base64', + '', + base64_encode(str_repeat('X', 2048)), + '--B2--', + ]); + + $mail = MailParser::parse('7', $raw); + + expect($mail->body)->toBe('Anbei die Liste.') + ->and($mail->attachments)->toHaveCount(1) + ->and($mail->attachments[0]['name'])->toBe('rechnung.pdf') + ->and($mail->attachments[0]['bytes'])->toBeGreaterThan(0); + + // And nothing anywhere in the value object carries the file itself. + expect(json_encode($mail))->not->toContain(base64_encode(str_repeat('X', 64))); +}); + +it('refuses a message with no sender rather than filing it under nobody', function () { + $raw = implode("\r\n", ['Message-ID: ', 'Subject: Ohne Absender'])."\r\n\r\nText"; + + expect(fn () => MailParser::parse('7', $raw))->toThrow(RuntimeException::class); +}); + +it('falls back to the UID when a message carries no Message-ID', function () { + // Required by the standard and missing in practice often enough that + // dropping the mail would lose real questions. + $raw = implode("\r\n", ['From: kunde@example.test', 'Subject: Ohne ID'])."\r\n\r\nText"; + + expect(MailParser::parse('42', $raw)->messageId)->toBe('uid-42'); +}); + +// ---- What the ingest decides ---- + +it('files a mail under the customer whose address it came from', function () { + $customer = Customer::factory()->create(['email' => 'kunde@example.test']); + $this->mailbox->mails = [fetched()]; + + app(IngestInboundMail::class)(); + + expect(InboundMail::query()->first()?->customer_id)->toBe($customer->id); +}); + +it('keeps a mail from an address nobody knows, unassigned rather than dropped', function () { + // The mail an operator must not miss: a new enquiry starts exactly this + // way, and hiding it because it matches no customer would lose the sale. + $this->mailbox->mails = [fetched('fremder@example.test')]; + + app(IngestInboundMail::class)(); + + $mail = InboundMail::query()->first(); + + expect($mail)->not->toBeNull() + ->and($mail->customer_id)->toBeNull() + ->and($mail->isUnassigned())->toBeTrue(); +}); + +it('decides whose it is by the address and by nothing else', function () { + // Not by a name in the subject and not by anything in the body: a mail is + // easy to write, and this decision attaches a stranger's words to a real + // customer's file. + Customer::factory()->create(['email' => 'echt@example.test', 'name' => 'Kanzlei Berger']); + + $this->mailbox->mails = [new FetchedMail( + uid: '1', messageId: 'm-forge', fromEmail: 'betrueger@example.test', + fromName: 'Kanzlei Berger', subject: 'Kanzlei Berger — dringend', + body: 'Bitte senden Sie die Zugangsdaten an diese Adresse.', receivedAt: now(), + )]; + + app(IngestInboundMail::class)(); + + expect(InboundMail::query()->first()?->customer_id)->toBeNull(); +}); + +it('takes the same message once, however often the mailbox offers it', function () { + // The fetch runs every couple of minutes, and a flag that did not stick on + // the server costs one re-read — not a second copy of the same question. + $this->mailbox->mails = [fetched()]; + + app(IngestInboundMail::class)(); + app(IngestInboundMail::class)(); + + expect(InboundMail::query()->count())->toBe(1); +}); + +it('flags a message on the server only once its row exists here', function () { + // The other order loses the message with nothing to show for it, and there + // is no second copy anywhere. + $this->mailbox->mails = [fetched()]; + + app(IngestInboundMail::class)(); + + expect($this->mailbox->seen)->toBe(['1']); +}); + +it('attaches the mail to the question the customer already has open', function () { + $customer = Customer::factory()->create(['email' => 'kunde@example.test']); + $request = SupportRequest::create([ + 'customer_id' => $customer->id, 'subject' => 'Migration', 'category' => 'other', + 'body' => 'Erste Frage', 'status' => 'open', 'reported_by' => $customer->email, + ]); + + $this->mailbox->mails = [fetched()]; + app(IngestInboundMail::class)(); + + expect(InboundMail::query()->first()?->support_request_id)->toBe($request->id); +}); + +it('fetches nothing at all while no mailbox is configured', function () { + $this->mailbox->configured = false; + $this->mailbox->mails = [fetched()]; + + expect(app(IngestInboundMail::class)())->toBe(0) + ->and(InboundMail::query()->count())->toBe(0); +}); + +// ---- The page an operator works in ---- + +it('puts what nobody could place at the top, where it cannot be missed', function () { + $customer = Customer::factory()->create(['email' => 'kunde@example.test', 'name' => 'Kanzlei Berger']); + + InboundMail::create([ + 'customer_id' => $customer->id, 'message_id' => 'm-known', 'from_email' => $customer->email, + 'subject' => 'Von einem Kunden', 'body' => 'Text', 'received_at' => now(), + ]); + InboundMail::create([ + 'message_id' => 'm-unknown', 'from_email' => 'fremder@example.test', + 'subject' => 'Von einem Fremden', 'body' => 'Text', 'received_at' => now()->subHour(), + ]); + + $page = Livewire::actingAs(operator('Owner'), 'operator')->test(Inbox::class); + + $page->assertViewHas('mails', fn ($mails) => $mails->first()->message_id === 'm-unknown') + ->assertSee(__('inbox.unassigned')); +}); + +it('lets an operator place a mail the matcher never could', function () { + // The customer writing from their private account: no matcher will + // recognise them, and an operator recognises them at once. + $customer = Customer::factory()->create(['name' => 'Kanzlei Berger']); + $mail = InboundMail::create([ + 'message_id' => 'm-private', 'from_email' => 'bea.berger@gmx.at', + 'subject' => 'Kurze Frage', 'body' => 'Text', 'received_at' => now(), + ]); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Inbox::class) + ->call('assign', $mail->uuid, $customer->uuid); + + expect($mail->fresh()->customer_id)->toBe($customer->id); +}); + +it('files a mail away without touching the copy on the mail server', function () { + $mail = InboundMail::create([ + 'message_id' => 'm-file', 'from_email' => 'kunde@example.test', + 'subject' => 'Erledigt', 'body' => 'Text', 'received_at' => now(), + ]); + + $page = Livewire::actingAs(operator('Owner'), 'operator')->test(Inbox::class); + $page->call('archive', $mail->uuid); + + expect($mail->fresh()->archived_at)->not->toBeNull() + // Nothing was asked of the mailbox: an application that deletes + // somebody's mail on their own server is one nobody hands a password. + ->and($this->mailbox->seen)->toBe([]); + + $page->assertViewHas('mails', fn ($mails) => $mails->isEmpty()); +}); + +it('says that no mailbox is set up, rather than showing an empty list nobody can explain', function () { + $this->mailbox->configured = false; + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Inbox::class) + ->assertSee(__('inbox.not_configured')); +}); + +it('keeps the inbox to operators who may see customers', function () { + Livewire::actingAs(operator('Read-only'), 'operator') + ->test(Inbox::class) + ->assertForbidden(); +}); diff --git a/tests/Feature/Admin/MailRegisterTest.php b/tests/Feature/Admin/MailRegisterTest.php index d0eaf67..59883fe 100644 --- a/tests/Feature/Admin/MailRegisterTest.php +++ b/tests/Feature/Admin/MailRegisterTest.php @@ -203,3 +203,49 @@ it('counts notices in words, not in brackets', function () { expect(trans_choice('admin.systems_notices', 1, ['n' => 1]))->toBe('1 Hinweis') ->and(trans_choice('admin.systems_notices', 4, ['n' => 4]))->toBe('4 Hinweise'); }); + +it('shows one customer their own things and never another customer', function () { + // Reported as "unterhalb steht immer das selbe, egal bei welchem Kunden". + // Worth proving rather than assuming: every list on this page is scoped to + // the customer in the URL, and two customers side by side must not bleed + // into each other. + $one = Customer::factory()->create(['name' => 'Erste Kanzlei']); + $two = Customer::factory()->create(['name' => 'Zweite Kanzlei']); + + SupportRequest::create([ + 'customer_id' => $one->id, 'subject' => 'Frage der Ersten', 'category' => 'other', + 'body' => 'Text der Ersten', 'status' => 'open', 'reported_by' => $one->email, + ]); + SupportRequest::create([ + 'customer_id' => $two->id, 'subject' => 'Frage der Zweiten', 'category' => 'other', + 'body' => 'Text der Zweiten', 'status' => 'open', 'reported_by' => $two->email, + ]); + + SentMail::create(['customer_id' => $one->id, 'to' => $one->email, 'subject' => 'Post an die Erste', 'sent_at' => now()]); + SentMail::create(['customer_id' => $two->id, 'to' => $two->email, 'subject' => 'Post an die Zweite', 'sent_at' => now()]); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(CustomerDetail::class, ['uuid' => $one->uuid]) + ->assertSee('Frage der Ersten') + ->assertSee('Post an die Erste') + ->assertDontSee('Frage der Zweiten') + ->assertDontSee('Post an die Zweite'); +}); + +it('names the status in words rather than printing the key at it', function () { + // The badge read "customers.status.active" — a lang key with no file behind + // it renders as itself, and Laravel says nothing about it. + $customer = Customer::factory()->create(['status' => 'active']); + SupportRequest::create([ + 'customer_id' => $customer->id, 'subject' => 'Frage', 'category' => 'other', + 'body' => 'Text', 'status' => 'open', 'reported_by' => $customer->email, + ]); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(CustomerDetail::class, ['uuid' => $customer->uuid]) + ->assertSee(__('admin.status.active')) + ->assertSee(__('support.status_open')) + // Neither key leaks through as its own name. + ->assertDontSee('customers.status.') + ->assertDontSee('support.status.'); +});