From e376b571be3c9dd953596d5b878c17f8f3239abb Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 21:41:29 +0200 Subject: [PATCH] Read the support mailbox into the console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No, 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, so that is what this is. Host, user and folder are console settings; the password is a vault entry, like every other credential that opens something. IMAP by hand rather than a dependency. Four verbs are needed — LOGIN, SELECT, SEARCH UNSEEN, FETCH — and the parsing that follows is the real work either way. It is bounded by what the console shows: a sender, a subject, the words a person wrote, and the NAMES of anything attached. Real mail from real people arrives encoded, so it handles RFC 2047 subjects (every German subject with an umlaut in it), quoted-printable and base64 bodies, Windows charsets, and multipart — text wins over HTML, because a console is not a mail client and the words are in the text part. A message it cannot make sense of is skipped and logged, 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. ATTACHMENTS ARE NOT STORED. Only their names and sizes, so an operator knows something was attached and can go and look if it matters. Keeping whatever a stranger chooses to send would make this application a malware store with a web interface in front of it. Whose a mail is, is decided by the sender's ADDRESS and by nothing else — not by a name in the subject, 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; there is a test that forges the name and the subject and still gets nothing. An address nobody recognises stays unassigned, shown FIRST and never hidden: that is a new enquiry, or a customer writing from their private account, and it is the mail that must not be missed. An operator can place it by hand from the same row. Nothing is deleted on the mail server, ever. Messages are flagged seen once the row is safely written — and only then, because flagging first and failing after would lose the message with no second copy anywhere. Filing one away is this console's own state; the mailbox is untouched. Two fixes on the way past: the customer page printed "customers.status.active" and "support.status.open" — a lang key with no file behind it renders as itself and Laravel says nothing — and the same page is now covered by a test proving each list is scoped to the customer in the URL, which was reported as "unterhalb steht immer das selbe". Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- app/Console/Commands/FetchInboundMail.php | 29 ++ app/Livewire/Admin/Inbox.php | 132 +++++++ app/Livewire/Admin/Integrations.php | 29 ++ app/Models/InboundMail.php | 71 ++++ app/Providers/AppServiceProvider.php | 7 + app/Services/Mail/FakeMailbox.php | 37 ++ app/Services/Mail/FetchedMail.php | 29 ++ app/Services/Mail/ImapMailbox.php | 273 ++++++++++++++ app/Services/Mail/InboundMailbox.php | 35 ++ app/Services/Mail/IngestInboundMail.php | 103 ++++++ app/Services/Mail/MailParser.php | 303 ++++++++++++++++ app/Services/Secrets/SecretVault.php | 5 + app/Support/Navigation.php | 7 +- app/Support/ProvisioningSettings.php | 20 ++ config/services.php | 25 ++ ...d_the_support_mailbox_into_the_console.php | 71 ++++ lang/de/admin.php | 1 + lang/de/inbox.php | 30 ++ lang/de/integrations.php | 8 + lang/de/secrets.php | 1 + lang/en/admin.php | 1 + lang/en/inbox.php | 30 ++ lang/en/integrations.php | 8 + lang/en/secrets.php | 1 + .../livewire/admin/customer-detail.blade.php | 9 +- .../views/livewire/admin/inbox.blade.php | 115 ++++++ .../livewire/admin/integrations.blade.php | 26 ++ routes/admin.php | 2 + routes/console.php | 12 + tests/Feature/Admin/InboundMailTest.php | 334 ++++++++++++++++++ tests/Feature/Admin/MailRegisterTest.php | 46 +++ 32 files changed, 1796 insertions(+), 6 deletions(-) create mode 100644 app/Console/Commands/FetchInboundMail.php create mode 100644 app/Livewire/Admin/Inbox.php create mode 100644 app/Models/InboundMail.php create mode 100644 app/Services/Mail/FakeMailbox.php create mode 100644 app/Services/Mail/FetchedMail.php create mode 100644 app/Services/Mail/ImapMailbox.php create mode 100644 app/Services/Mail/InboundMailbox.php create mode 100644 app/Services/Mail/IngestInboundMail.php create mode 100644 app/Services/Mail/MailParser.php create mode 100644 database/migrations/2026_07_30_200000_read_the_support_mailbox_into_the_console.php create mode 100644 lang/de/inbox.php create mode 100644 lang/en/inbox.php create mode 100644 resources/views/livewire/admin/inbox.blade.php create mode 100644 tests/Feature/Admin/InboundMailTest.php 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.'); +});