diff --git a/app/Mail/CloudResumedMail.php b/app/Mail/CloudResumedMail.php index 66ffd7c..ab8c871 100644 --- a/app/Mail/CloudResumedMail.php +++ b/app/Mail/CloudResumedMail.php @@ -4,6 +4,7 @@ namespace App\Mail; use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -29,7 +30,7 @@ class CloudResumedMail extends Mailable implements ShouldQueue public string $currency, public string $billingUrl, ) { - $this->mailer('cp_'.MailPurpose::BILLING); + $this->mailer(MailCatalogue::mailer('cloud-resumed')); } public function envelope(): Envelope diff --git a/app/Mail/CloudSuspendedMail.php b/app/Mail/CloudSuspendedMail.php index f2d3b01..8e2841b 100644 --- a/app/Mail/CloudSuspendedMail.php +++ b/app/Mail/CloudSuspendedMail.php @@ -4,6 +4,7 @@ namespace App\Mail; use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -29,7 +30,7 @@ class CloudSuspendedMail extends Mailable implements ShouldQueue public string $currency, public string $billingUrl, ) { - $this->mailer('cp_'.MailPurpose::BILLING); + $this->mailer(MailCatalogue::mailer('cloud-suspended')); } public function envelope(): Envelope diff --git a/app/Mail/DormantAccountWarningMail.php b/app/Mail/DormantAccountWarningMail.php index efdc418..2bd6294 100644 --- a/app/Mail/DormantAccountWarningMail.php +++ b/app/Mail/DormantAccountWarningMail.php @@ -5,6 +5,7 @@ namespace App\Mail; use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\User; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -30,7 +31,7 @@ class DormantAccountWarningMail extends Mailable implements ShouldQueue public function __construct(public User $user, public int $days) { - $this->mailer('cp_'.MailPurpose::SYSTEM); + $this->mailer(MailCatalogue::mailer('dormant-warning')); } public function envelope(): Envelope diff --git a/app/Mail/DunningNoticeMail.php b/app/Mail/DunningNoticeMail.php index 037de2b..bd670fb 100644 --- a/app/Mail/DunningNoticeMail.php +++ b/app/Mail/DunningNoticeMail.php @@ -4,6 +4,7 @@ namespace App\Mail; use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -45,7 +46,7 @@ class DunningNoticeMail extends Mailable implements ShouldQueue public string $billingUrl, public ?string $suspendOn = null, ) { - $this->mailer('cp_'.MailPurpose::BILLING); + $this->mailer(MailCatalogue::mailer('dunning-'.$this->level)); } public function envelope(): Envelope diff --git a/app/Mail/InvoiceMail.php b/app/Mail/InvoiceMail.php index a897639..412dac3 100644 --- a/app/Mail/InvoiceMail.php +++ b/app/Mail/InvoiceMail.php @@ -7,6 +7,7 @@ use App\Mail\Concerns\SendsFromMailbox; use App\Models\Invoice; use App\Services\Billing\InvoiceMath; use App\Services\Billing\InvoiceRenderer; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -38,7 +39,7 @@ class InvoiceMail extends Mailable implements ShouldQueue // no-reply@ login is answered "553 Sender address rejected: not owned by // user". Setting the From from a mailbox and leaving the mailer at the // default is a mail that renders perfectly and never arrives. - $this->mailer('cp_'.MailPurpose::BILLING); + $this->mailer(MailCatalogue::mailer('invoice')); } public function envelope(): Envelope diff --git a/app/Mail/MaintenanceAnnouncementMail.php b/app/Mail/MaintenanceAnnouncementMail.php index 4002564..722125f 100644 --- a/app/Mail/MaintenanceAnnouncementMail.php +++ b/app/Mail/MaintenanceAnnouncementMail.php @@ -6,6 +6,7 @@ use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\Customer; use App\Models\MaintenanceWindow; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -28,7 +29,7 @@ class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue ) { // Names the mailer, which is all that is serialised into the queue — // the credentials behind it are resolved when the worker sends. - $this->mailer('cp_'.MailPurpose::MAINTENANCE); + $this->mailer(MailCatalogue::mailer('maintenance-announcement')); } public function headers(): Headers diff --git a/app/Mail/MaintenanceCancelledMail.php b/app/Mail/MaintenanceCancelledMail.php index b83887e..e47c5fa 100644 --- a/app/Mail/MaintenanceCancelledMail.php +++ b/app/Mail/MaintenanceCancelledMail.php @@ -6,6 +6,7 @@ use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\Customer; use App\Models\MaintenanceWindow; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -26,7 +27,7 @@ class MaintenanceCancelledMail extends Mailable implements ShouldQueue public MaintenanceWindow $window, public Customer $customer, ) { - $this->mailer('cp_'.MailPurpose::MAINTENANCE); + $this->mailer(MailCatalogue::mailer('maintenance-cancelled')); } public function headers(): Headers diff --git a/app/Mail/NewDeviceSignInMail.php b/app/Mail/NewDeviceSignInMail.php index 7634ec1..b3e286f 100644 --- a/app/Mail/NewDeviceSignInMail.php +++ b/app/Mail/NewDeviceSignInMail.php @@ -5,6 +5,7 @@ namespace App\Mail; use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\UserDevice; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -32,7 +33,7 @@ class NewDeviceSignInMail extends Mailable implements ShouldQueue public UserDevice $device, public string $guard, ) { - $this->mailer('cp_'.MailPurpose::SYSTEM); + $this->mailer(MailCatalogue::mailer('new-device')); } public function envelope(): Envelope diff --git a/app/Mail/OperatorMessageMail.php b/app/Mail/OperatorMessageMail.php index 7016784..4f851e4 100644 --- a/app/Mail/OperatorMessageMail.php +++ b/app/Mail/OperatorMessageMail.php @@ -5,6 +5,7 @@ namespace App\Mail; use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\Customer; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -34,7 +35,7 @@ class OperatorMessageMail extends Mailable implements ShouldQueue public string $subjectLine, public string $bodyText, ) { - $this->mailer('cp_'.MailPurpose::SUPPORT); + $this->mailer(MailCatalogue::mailer('operator-message')); } public function envelope(): Envelope diff --git a/app/Mail/OrderConfirmationMail.php b/app/Mail/OrderConfirmationMail.php index 0e67669..9dc9db4 100644 --- a/app/Mail/OrderConfirmationMail.php +++ b/app/Mail/OrderConfirmationMail.php @@ -6,6 +6,7 @@ use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\Datacenter; use App\Models\Order; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -40,7 +41,7 @@ class OrderConfirmationMail extends Mailable implements ShouldQueue // no-reply@ login is answered "553 Sender address rejected: not owned by // user". Setting the From from a mailbox and leaving the mailer at the // default is a mail that renders perfectly and never arrives. - $this->mailer('cp_'.MailPurpose::BILLING); + $this->mailer(MailCatalogue::mailer('order-confirmation')); } public function envelope(): Envelope diff --git a/app/Mail/ResetPasswordMail.php b/app/Mail/ResetPasswordMail.php index 37828cd..d5c4319 100644 --- a/app/Mail/ResetPasswordMail.php +++ b/app/Mail/ResetPasswordMail.php @@ -5,6 +5,7 @@ namespace App\Mail; use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\User; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -29,7 +30,7 @@ class ResetPasswordMail extends Mailable implements ShouldQueue public function __construct(public User $user, public string $url, public int $minutes) { - $this->mailer('cp_'.MailPurpose::SYSTEM); + $this->mailer(MailCatalogue::mailer('reset-password')); } public function envelope(): Envelope diff --git a/app/Mail/SecurityBlockMail.php b/app/Mail/SecurityBlockMail.php index 72c38a5..2efd426 100644 --- a/app/Mail/SecurityBlockMail.php +++ b/app/Mail/SecurityBlockMail.php @@ -5,6 +5,7 @@ namespace App\Mail; use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\SecurityBlock; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -14,20 +15,37 @@ use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; /** - * "Eine Adresse wurde wegen Anmeldeversuchen an Ihrer Instanz gesperrt." + * "Eine Adresse wurde wegen Anmeldeversuchen gesperrt." + * + * ZWEI Empfängerkreise, eine Mailart — und das ist keine Bequemlichkeit, + * sondern der Grund, warum diese Klasse beide Fälle ausdrücklich auseinander + * hält: + * + * - **Instanz-Sperre** → an den Inhaber. Text in der zweiten Person („bei + * Ihrer Cloud"), Anrede aus dem Kundendatensatz, Knopf auf die + * Sicherheitsseite des Portals (`portal.security`), wo er die Sperre + * einsehen und vorzeitig aufheben kann. + * - **Host-Sperre** → an die Betreiber mit `hosts.manage` (siehe + * `BlockAddress::notifyHostManagers()`). Eigener Text mit dem NAMEN des + * Hosts, keine Anrede, und der Knopf zeigt auf die Host-Detailseite der + * Konsole. + * + * Der Unterschied ist nicht kosmetisch: ein Betreiber hat im Portal kein + * Konto, und Host-Sperren erscheinen dort ohnehin nie. Ein Knopf auf + * `portal.security` in einer Host-Mail führt also zwangsläufig ins Leere — + * und ohne den Hostnamen im Text stünde in einer Meldung an jemanden mit + * einem Dutzend Maschinen nicht, welche gemeint ist. * * Aus dem SYSTEM-Postfach, genau wie NewDeviceSignInMail: das ist kein - * Gespräch, und eine Antwort darauf soll kein Ticket öffnen. + * Gespräch, und eine Antwort darauf soll kein Ticket öffnen. Über die Wegwahl + * (`MailRoute`, Mailart `security-block`) kann der Betreiber sie auf ein + * anderes Postfach legen — dann geht sie auch über DESSEN SMTP-Konto, nicht + * nur mit dessen Absenderzeile. * - * Die Drossel — höchstens eine Mail je Instanz und Stunde — sitzt in + * Die Drossel — höchstens eine Mail je Subjekt und Stunde — sitzt in * BlockAddress, nicht hier: diese Klasse weiß nichts davon, ob sie gerade die * erste oder die zehnte Sperre dieser Stunde beschreibt, und muss es auch * nicht wissen. - * - * Der Link führt auf die Sicherheitsseite des Portals (Aufgabe 6, `portal. - * security`), wo die Sperre eingesehen und vorzeitig aufgehoben werden kann. - * Eine Warnung, deren einziger Rat "handeln Sie" ist, ist eine Warnung ohne - * Handgriff. */ class SecurityBlockMail extends Mailable implements ShouldQueue { @@ -35,20 +53,26 @@ class SecurityBlockMail extends Mailable implements ShouldQueue public function __construct(public SecurityBlock $block) { - $this->mailer('cp_'.MailPurpose::SYSTEM); + $this->mailer(MailCatalogue::mailer('security-block')); } public function envelope(): Envelope { + $host = $this->block->host; + return $this->mailboxEnvelope( MailPurpose::SYSTEM, - __('security.mail_subject'), + $host !== null + ? __('security.mail_subject_host', ['host' => $host->name]) + : __('security.mail_subject'), 'security-block', ); } public function content(): Content { + $host = $this->block->host; + // instance() statt eines eigenen Customer-Parameters: der Aufrufer // (BlockAddress) hat ohnehin nur den Datensatz, und ein zweiter Weg, // an dieselbe Instanz zu kommen, wäre eine zweite Stelle, an der der @@ -56,12 +80,22 @@ class SecurityBlockMail extends Mailable implements ShouldQueue $customer = $this->block->instance?->customer; return new Content(view: 'mail.security-block', with: [ + // Der Name des Hosts ist das Unterscheidungsmerkmal für die + // Ansicht: gesetzt heißt Host-Sperre, null heißt Instanz-Sperre. + // Ein zweites Flag daneben wäre eine zweite Wahrheit. + 'hostName' => $host?->name, 'name' => (string) ($customer?->contact_name ?: $customer?->name ?: ''), 'ip' => $this->block->ip, 'attempts' => $this->block->attempts, 'blockedAt' => $this->block->blocked_at, 'expiresAt' => $this->block->expires_at, - 'securityUrl' => route('portal.security'), + // Der Betreiber hat im Portal kein Konto, und Host-Sperren stehen + // dort nie. Sein Weg zum Handgriff ist die Host-Detailseite der + // Konsole, auf der die Host-Sperren mit ihrem Aufheben-Knopf + // stehen (Aufgabe 6, App\Livewire\Admin\HostDetail). + 'securityUrl' => $host !== null + ? route('admin.hosts.show', $host) + : route('portal.security'), ]); } } diff --git a/app/Mail/Transport/MailboxTransport.php b/app/Mail/Transport/MailboxTransport.php index 4bda56c..a5ef413 100644 --- a/app/Mail/Transport/MailboxTransport.php +++ b/app/Mail/Transport/MailboxTransport.php @@ -4,6 +4,7 @@ namespace App\Mail\Transport; use App\Models\Mailbox; use App\Services\Mail\MailboxResolver; +use App\Services\Mail\MailRoute; use App\Services\Mail\MailTlsPolicy; use App\Support\MailDelivery; use App\Support\Settings; @@ -54,7 +55,18 @@ class MailboxTransport implements TransportInterface private ?string $fingerprint = null; - public function __construct(private readonly string $purpose) {} + /** + * $mailKey names ONE mail type from MailCatalogue. Given, this transport + * asks MailRoute the same question SendsFromMailbox asks for the envelope, + * so the account that signs in is the account the From address belongs to. + * Without it — every mailer that predates the per-mail-type ones, and + * everything with no key of its own — the purpose alone decides, exactly + * as before. + */ + public function __construct( + private readonly string $purpose, + private readonly ?string $mailKey = null, + ) {} public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage { @@ -63,7 +75,7 @@ class MailboxTransport implements TransportInterface public function __toString(): string { - return 'mailbox://'.$this->purpose.'/'.($this->describe() ?? 'unconfigured'); + return 'mailbox://'.($this->mailKey ?? $this->purpose).'/'.($this->describe() ?? 'unconfigured'); } /** What this transport currently points at — used by __toString and tests. */ @@ -107,7 +119,14 @@ class MailboxTransport implements TransportInterface return [$default ?? 'null', null]; } - $box = app(MailboxResolver::class)->for($this->purpose); + // The SAME lookup SendsFromMailbox::mailboxAddresses() runs for the + // envelope, not a second opinion about it. When they disagreed, the + // mail went out with one mailbox's From over another mailbox's login — + // renders perfectly, queues without complaint, and is refused by any + // server that checks who owns the sender address. + $box = $this->mailKey !== null + ? MailRoute::purposeOrMailbox($this->mailKey, $this->purpose) + : app(MailboxResolver::class)->for($this->purpose); return $box !== null && $box->isConfigured() ? ['mailbox', $box] @@ -126,7 +145,9 @@ class MailboxTransport implements TransportInterface // usable credentials (no row at all, or one with no password), // and switching to yet another address would only hide that. throw new RuntimeException( - "No configured mailbox for mail purpose [{$this->purpose}]." + $this->mailKey !== null + ? "No configured mailbox for mail type [{$this->mailKey}] (purpose [{$this->purpose}])." + : "No configured mailbox for mail purpose [{$this->purpose}]." ); } diff --git a/app/Mail/VerifyEmailMail.php b/app/Mail/VerifyEmailMail.php index af0d525..7c55a27 100644 --- a/app/Mail/VerifyEmailMail.php +++ b/app/Mail/VerifyEmailMail.php @@ -5,6 +5,7 @@ namespace App\Mail; use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\User; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -31,7 +32,7 @@ class VerifyEmailMail extends Mailable implements ShouldQueue public function __construct(public User $user) { - $this->mailer('cp_'.MailPurpose::SYSTEM); + $this->mailer(MailCatalogue::mailer('verify-email')); } public function envelope(): Envelope diff --git a/app/Notifications/CloudReady.php b/app/Notifications/CloudReady.php index d2eb3d9..2967eef 100644 --- a/app/Notifications/CloudReady.php +++ b/app/Notifications/CloudReady.php @@ -5,6 +5,7 @@ namespace App\Notifications; use App\Mail\Concerns\SendsFromMailbox; use App\Models\Datacenter; use App\Models\Instance; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use App\Support\ProvisioningSettings; use Illuminate\Bus\Queueable; @@ -48,7 +49,10 @@ class CloudReady extends Notification [$from, $replyTo] = $this->mailboxAddresses(MailPurpose::PROVISIONING, 'cloud-ready'); - $message = (new MailMessage)->mailer('cp_'.MailPurpose::PROVISIONING); + // Derselbe Schlüssel wie eine Zeile darüber: Umschlag UND Anmeldung + // folgen der Wegwahl, sonst ginge diese Mail mit dem From des einen + // Postfachs durch die SMTP-Sitzung eines anderen. + $message = (new MailMessage)->mailer(MailCatalogue::mailer('cloud-ready')); if ($from !== null) { $message->from($from->address, $from->name); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2b69938..3917aa0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -94,7 +94,12 @@ class AppServiceProvider extends ServiceProvider // Registered as a DRIVER, not as configuration: the closure runs when a // mailer is first resolved — inside the queue worker, at send time — // so nothing here reads the database while the application boots. - Mail::extend('mailbox', fn (array $config) => new MailboxTransport($config['purpose'])); + // `mail_key` steht nur an den Mailern, die eine Mailart benennen (siehe + // config/mail.php). Fehlt er, entscheidet wie bisher allein der Zweck. + Mail::extend('mailbox', fn (array $config) => new MailboxTransport( + $config['purpose'], + $config['mail_key'] ?? null, + )); // The one thing the portal navigation has to ask that is not a // permission: the sidebar filters on can(), and a customer holds no diff --git a/app/Services/Mail/MailCatalogue.php b/app/Services/Mail/MailCatalogue.php index a89ac00..7792e86 100644 --- a/app/Services/Mail/MailCatalogue.php +++ b/app/Services/Mail/MailCatalogue.php @@ -43,8 +43,13 @@ final class MailCatalogue 'dunning-3' => ['label' => '3. Mahnung (letzte vor Abschaltung)', 'purpose' => MailPurpose::BILLING], 'cloud-suspended' => ['label' => 'Cloud abgeschaltet', 'purpose' => MailPurpose::BILLING], 'cloud-resumed' => ['label' => 'Cloud läuft wieder', 'purpose' => MailPurpose::BILLING], - // Sicherheit: die eine Meldung des Frühwarnsystems (BlockAddress), - // wenn eine Adresse an der Instanz eines Kunden gesperrt wird. + // Sicherheit: die eine Meldung des Frühwarnsystems (BlockAddress). + // EIN Eintrag, aber ZWEI Empfängerkreise — an den Inhaber, wenn + // eine Adresse an seiner Instanz gesperrt wird, und an die + // Betreiber mit `hosts.manage`, wenn es an einem Host passiert. + // Beide Fälle tragen denselben Schlüssel, gehen also gemeinsam + // dorthin, wohin die Wegwahl sie legt; welchen Text und welchen + // Knopf sie bekommen, entscheidet SecurityBlockMail. 'security-block' => ['label' => 'Adresse wegen Anmeldeversuchen gesperrt', 'purpose' => MailPurpose::SYSTEM], ]; } @@ -58,4 +63,27 @@ final class MailCatalogue { return self::all()[$key]['purpose'] ?? MailPurpose::SYSTEM; } + + /** + * Der Mailer dieser Mailart — also das SMTP-Konto, über das sie geht. + * + * Nicht `'cp_'.$purpose`, und das ist der ganze Punkt: die Wegwahl darf + * nicht nur die Absenderzeile verschieben. Wer `security-block` auf `info@` + * legt, will die Mail AUS `info@`, nicht mit `From: info@` durch die + * Anmeldung von `no-reply@` — viele Server weisen genau das ab. `config/ + * mail.php` erzeugt zu jedem Schlüssel dieser Liste einen Mailer mit + * derselben `mail_key`, und `MailboxTransport` fragt damit dieselbe + * `MailRoute`, die auch den Umschlag entscheidet. + * + * Ohne Eintrag in der Wegwahl fällt beides auf den Zweck zurück, das + * Verhalten bleibt also unverändert. + */ + public static function mailer(string $key): string + { + // Ein Schlüssel, den diese Liste nicht kennt, bekommt den Mailer seines + // Zwecks: config/mail.php erzeugt nur zu den Einträgen hier einen, und + // Laravel würfe sonst "Mailer [...] is not defined" — die Mail ginge + // dann gar nicht, statt wie zuvor aus dem Zweck-Postfach. + return isset(self::all()[$key]) ? 'cp_mail_'.$key : 'cp_'.self::purpose($key); + } } diff --git a/app/Services/Mail/MailPreviews.php b/app/Services/Mail/MailPreviews.php index 013014d..f74951b 100644 --- a/app/Services/Mail/MailPreviews.php +++ b/app/Services/Mail/MailPreviews.php @@ -236,7 +236,7 @@ class MailPreviews // mailbox owns, over whichever account mail.default logs in // with, which is the "553 Sender address rejected" every // properly configured mail server answers with. - $this->mailer('cp_'.MailPurpose::PROVISIONING); + $this->mailer(MailCatalogue::mailer('cloud-ready')); } public function envelope(): Envelope diff --git a/config/mail.php b/config/mail.php index a980d38..4710dbf 100644 --- a/config/mail.php +++ b/config/mail.php @@ -1,5 +1,37 @@ `) verschob bisher nur den UMSCHLAG. Der Mailer blieb +| der des Zwecks, und `MailboxTransport` meldete sich mit dessen Zugangsdaten +| an — legte der Betreiber `security-block` auf `info@`, ging die Mail also mit +| `From: info@` durch die SMTP-Sitzung von `no-reply@`. Ein Server, der prüft, +| ob ein Konto seine Absenderadresse besitzt, weist das ab ("553 5.7.1 Sender +| address rejected: not owned by user"), und dann verschwindet ausgerechnet die +| Mail, die gerade umgelegt wurde. Wegwahl und Anmeldung müssen dieselbe Frage +| beantworten, also stellt der Mailer sie auch: er kennt die Mailart und fragt +| damit dieselbe MailRoute wie der Umschlag. +| +| Erzeugt statt aufgelistet, weil MailCatalogue ausdrücklich die EINE Liste +| ist — eine zweite hier wäre die, die beim siebzehnten Mail vergessen wird. +| Genauso statisch wie die fünf unten: ein reines Array, keine Datenbank, also +| auch unter `config:cache` unverändert. +*/ +$perMailType = []; + +foreach (MailCatalogue::all() as $mailKey => $entry) { + $perMailType[MailCatalogue::mailer($mailKey)] = [ + 'transport' => 'mailbox', + 'purpose' => $entry['purpose'], + 'mail_key' => $mailKey, + ]; +} + return [ /* @@ -104,6 +136,11 @@ return [ | time — see App\Services\Secrets\SecretVault rule 3, which this | follows: an overlay at boot costs a query on every request and leaves | long-running queue workers holding whatever was true when they started. + | + | Still used by everything that has no key of its own in MailCatalogue + | (ContactRequestMail, MailboxTester, the readiness checks). A mail type + | that IS in the catalogue goes over its own mailer instead — see + | $perMailType at the top of this file for why. */ 'cp_maintenance' => ['transport' => 'mailbox', 'purpose' => 'maintenance'], 'cp_provisioning' => ['transport' => 'mailbox', 'purpose' => 'provisioning'], @@ -111,6 +148,8 @@ return [ 'cp_billing' => ['transport' => 'mailbox', 'purpose' => 'billing'], 'cp_system' => ['transport' => 'mailbox', 'purpose' => 'system'], + ...$perMailType, + ], /* diff --git a/lang/de/security.php b/lang/de/security.php index d4e8fc8..2b3af2e 100644 --- a/lang/de/security.php +++ b/lang/de/security.php @@ -41,10 +41,14 @@ return [ 'tell_us' => 'Unsicher, ob eine Nachricht echt ist? Schicken Sie sie uns weiter, bevor Sie klicken. Das kostet Sie zwei Minuten und uns nichts.', - // Die Mail aus BlockAddress, wenn eine Adresse an der Instanz eines Kunden - // gesperrt wird: Adresse, Zeitpunkt, Anzahl der Versuche und wann die - // Sperre von selbst abläuft — eine Warnung, deren einziger Rat "handeln - // Sie" ist, ist eine Warnung ohne Handgriff. + // Die Mail aus BlockAddress: Adresse, Zeitpunkt, Anzahl der Versuche und + // wann die Sperre von selbst abläuft — eine Warnung, deren einziger Rat + // "handeln Sie" ist, ist eine Warnung ohne Handgriff. + // + // Zwei Fassungen, weil es zwei Empfängerkreise gibt: die Schlüssel ohne + // Zusatz gehen an den INHABER einer Instanz, die mit `_host` an die + // BETREIBER. Ein Betreiber hat im Portal kein Konto, Host-Sperren stehen + // dort nie, und „Ihre Cloud" ist für ihn schlicht falsch. 'mail_subject' => 'Adresse wegen Anmeldeversuchen gesperrt', 'mail_heading' => 'Eine Adresse wurde gesperrt', 'mail_preheader' => ':ip wurde wegen wiederholter Anmeldeversuche gesperrt.', @@ -60,6 +64,18 @@ return [ // Empfänger die Meldung für falsch und nimmt die nächste nicht mehr ernst. 'mail_false_alarm' => 'Diese Meldung kommt auch, wenn ein eigenes Gerät oder eine eigene Anwendung mit einem falschen Passwort auf Ihre Cloud zugreift.', + // Die Host-Fassung. Der Name des Hosts steht im Betreff, in der Einleitung + // UND als eigene Zeile in der Tabelle: wer ein Dutzend Maschinen betreut, + // muss aus der Meldung selbst erkennen können, welche gemeint ist. + 'mail_subject_host' => 'Adresse an Host :host gesperrt', + 'mail_heading_host' => 'Eine Adresse wurde an einem Host gesperrt', + 'mail_preheader_host' => ':ip wurde am Host :host wegen wiederholter Anmeldeversuche gesperrt.', + 'mail_intro_host' => 'Von der Adresse :ip gab es innerhalb kurzer Zeit :attempts fehlgeschlagene SSH-Anmeldungen am Host :host. Die Adresse wurde deshalb in der Firewall dieses Hosts vorübergehend gesperrt.', + 'field_host' => 'Host', + 'mail_note_host' => 'Die Sperre endet von selbst zum genannten Zeitpunkt. In der Konsole steht sie auf der Detailseite des Hosts und kann dort auch vorzeitig aufgehoben werden.', + 'mail_action_host' => 'Host in der Konsole öffnen', + 'mail_false_alarm_host' => 'Bestehende Verbindungen bleiben unberührt — die Regel steht unter „established,related accept". Wer schon angemeldet war, merkt von der Sperre nichts.', + // Aufgabe 6: die Sicherheitsseite selbst — App\Livewire\Security, das Ziel // hinter dem Knopf 'mail_action' oben und dem neuen Portal-Navigationspunkt. 'blocks_title' => 'Sicherheitssperren', diff --git a/lang/en/security.php b/lang/en/security.php index 6062240..cbb24a6 100644 --- a/lang/en/security.php +++ b/lang/en/security.php @@ -41,10 +41,15 @@ return [ 'tell_us' => 'Not sure whether a message is genuine? Forward it to us before you click. It costs you two minutes and us nothing.', - // The mail from BlockAddress when an address is blocked at a customer's - // instance: the address, the time, the number of attempts and when the - // block lifts on its own — a warning whose only advice is "take action" - // is a warning that names a problem and hands back nothing to do about it. + // The mail from BlockAddress: the address, the time, the number of + // attempts and when the block lifts on its own — a warning whose only + // advice is "take action" is a warning that names a problem and hands back + // nothing to do about it. + // + // Two versions, because there are two audiences: the keys without a suffix + // go to the OWNER of an instance, the `_host` ones to the OPERATORS. An + // operator has no portal account, host blocks never appear there, and + // "your cloud" is simply wrong for them. 'mail_subject' => 'Address blocked after sign-in attempts', 'mail_heading' => 'An address was blocked', 'mail_preheader' => ':ip was blocked after repeated sign-in attempts.', @@ -60,6 +65,18 @@ return [ // decides the warning is wrong and stops reading the next one. 'mail_false_alarm' => 'You will also get this message if a device or application of your own is using an outdated password against your cloud.', + // The host version. The host's name is in the subject, in the opening line + // AND as its own row in the table: whoever looks after a dozen machines + // has to be able to tell from the message itself which one is meant. + 'mail_subject_host' => 'Address blocked on host :host', + 'mail_heading_host' => 'An address was blocked on a host', + 'mail_preheader_host' => ':ip was blocked on host :host after repeated sign-in attempts.', + 'mail_intro_host' => 'Address :ip made :attempts failed SSH sign-ins against host :host in a short time. The address has been blocked in that host\'s firewall for the time being.', + 'field_host' => 'Host', + 'mail_note_host' => 'The block lifts on its own at the time above. It is listed on the host\'s detail page in the console, where it can also be lifted early.', + 'mail_action_host' => 'Open host in the console', + 'mail_false_alarm_host' => 'Existing connections are untouched — the rule sits below "established,related accept". Anyone already signed in notices nothing.', + // Task 6: the security page itself — App\Livewire\Security, the target // behind the 'mail_action' button above and the new portal nav entry. 'blocks_title' => 'Security blocks', diff --git a/resources/views/mail/security-block.blade.php b/resources/views/mail/security-block.blade.php index b35d9bc..13c3881 100644 --- a/resources/views/mail/security-block.blade.php +++ b/resources/views/mail/security-block.blade.php @@ -1,21 +1,38 @@ +{{-- Zwei Empfaengerkreise, eine Ansicht: `$hostName` gesetzt heisst + Host-Sperre (an die Betreiber), null heisst Instanz-Sperre (an den + Inhaber). Siehe App\Mail\SecurityBlockMail — dort steht, warum die beiden + Faelle nicht denselben Text und nicht denselben Knopf bekommen duerfen. --}} +@php($istHost = $hostName !== null) + -

{{ __('security.mail_intro', ['ip' => $ip, 'attempts' => $attempts]) }}

+

{{ $istHost ? __('security.mail_intro_host', ['ip' => $ip, 'attempts' => $attempts, 'host' => $hostName]) : __('security.mail_intro', ['ip' => $ip, 'attempts' => $attempts]) }}

{{-- Die Fakten, als Tabelle statt als Satz: eine Sperre wird nachgeschlagen, nicht gelesen wie eine Geschichte. --}} + @if ($istHost) + + + + + + + + + @else + @endif @@ -32,10 +49,10 @@ @@ -43,7 +60,7 @@ diff --git a/tests/Feature/Admin/MailPreviewTest.php b/tests/Feature/Admin/MailPreviewTest.php index 43076de..91ebf07 100644 --- a/tests/Feature/Admin/MailPreviewTest.php +++ b/tests/Feature/Admin/MailPreviewTest.php @@ -88,7 +88,14 @@ it('sends each preview through the mailer the mail itself asks for', function () ->test(MailPreview::class) ->call('sendToMe', 'operator-message'); - Mail::assertSent(App\Mail\OperatorMessageMail::class, fn ($mail) => $mail->mailer === 'cp_support'); + // Der Mailer der MAILART, nicht der des Zwecks: seit die Wegwahl auch das + // Konto verschiebt, hat jede Mailart im Katalog einen eigenen. Ueber + // MailCatalogue gefragt, damit diese Zusicherung nicht von der einen Liste + // abdriften kann, die sie benennt. + Mail::assertSent( + App\Mail\OperatorMessageMail::class, + fn ($mail) => $mail->mailer === App\Services\Mail\MailCatalogue::mailer('operator-message'), + ); }); it('gives every preview a sender its own mailbox owns', function () { @@ -113,11 +120,19 @@ it('gives every preview a sender its own mailbox owns', function () { foreach (array_keys(app(MailPreviews::class)->all()) as $key) { $mailable = app(MailPreviews::class)->make($key); - // Which mailer it will go out over, and therefore which account signs in. - expect($mailable->mailer)->toBeString()->and($mailable->mailer)->toStartWith('cp_'); + // Which mailer it will go out over, and therefore which account signs + // in. Read out of the mailer's own configuration rather than parsed out + // of its NAME: since the routing fix each catalogued mail type has a + // mailer of its own (cp_mail_), and a test that peels 'cp_' + // off the front would be asserting the naming scheme instead of the + // invariant. + expect($mailable->mailer)->toBeString(); - $purpose = str_replace('cp_', '', $mailable->mailer); - $mailable->assertFrom($purpose.'@clupilot.test'); + $mailer = (array) config('mail.mailers.'.$mailable->mailer); + + expect($mailer['transport'] ?? null)->toBe('mailbox', "[{$key}] geht nicht ueber ein Postfach"); + + $mailable->assertFrom($mailer['purpose'].'@clupilot.test'); } }); diff --git a/tests/Feature/Admin/MailRoutingTest.php b/tests/Feature/Admin/MailRoutingTest.php index 5007c3a..b97a883 100644 --- a/tests/Feature/Admin/MailRoutingTest.php +++ b/tests/Feature/Admin/MailRoutingTest.php @@ -5,6 +5,7 @@ use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use App\Services\Mail\MailRoute; use App\Support\Settings; +use Illuminate\Support\Facades\Mail; // Blank slate statt der migrationseigenen fuenf (no-reply, support, billing, // office, info): drei der Tests unten legen selbst ein Postfach mit dem @@ -61,6 +62,44 @@ it('kennt zu jeder Mailart einen Vorgabe-Zweck', function () { } }); +// ---- Die Wegwahl verschiebt das KONTO, nicht nur den Absender ---- + +it('meldet sich mit gesetzter Wegwahl beim Konto des gewaehlten Postfachs an', function () { + // Der Umschlag folgte der Wegwahl, die SMTP-Sitzung nicht: `From: info@` + // ging durch die Anmeldung von `no-reply@`. Ein Server, der prueft, ob ein + // Konto seine Absenderadresse besitzt, weist das ab ("553 5.7.1 Sender + // address rejected") — und dann verschwindet ausgerechnet die Mail, die der + // Betreiber gerade umgelegt hat. + config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); + config()->set('mail.default', 'smtp'); // Zustellung an, wie im Betrieb + Settings::set('mail.host', 'mail.example.test'); + Settings::set('mail.port', 587); + + Mailbox::factory()->create(['key' => 'no-reply', 'address' => 'no-reply@clupilot.com', 'active' => true]); + Mailbox::factory()->create(['key' => 'info', 'address' => 'info@clupilot.com', 'active' => true]); + Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply'); + + $mailer = MailCatalogue::mailer('security-block'); + + // Ohne Eintrag: das Postfach des Zwecks, genau wie bisher. + expect((string) Mail::mailer($mailer)->getSymfonyTransport()) + ->toContain('no-reply@clupilot.com'); + + Settings::set(MailRoute::settingKey('security-block'), 'info'); + + // Frisch aufloesen: der MailManager haelt einen einmal gebauten Mailer. + Mail::forgetMailers(); + + expect((string) Mail::mailer($mailer)->getSymfonyTransport()) + ->toContain('info@clupilot.com') + ->not->toContain('no-reply@clupilot.com'); + + // Und die Nachbarn bleiben, wo sie waren — die Wegwahl gilt fuer GENAU + // eine Mailart, auch was das Konto angeht. + expect((string) Mail::mailer(MailCatalogue::mailer('new-device'))->getSymfonyTransport()) + ->toContain('no-reply@clupilot.com'); +}); + it('fuehrt die Liste der Mailarten nur an EINER Stelle', function () { // Zwei Listen bedeuten, dass die zweite beim siebzehnten Mail vergessen // wird. Die Vorschau muss aus dem Katalog lesen. diff --git a/tests/Feature/Mail/SenderAddressTest.php b/tests/Feature/Mail/SenderAddressTest.php index e6efc81..8a6fb16 100644 --- a/tests/Feature/Mail/SenderAddressTest.php +++ b/tests/Feature/Mail/SenderAddressTest.php @@ -8,6 +8,7 @@ use App\Models\Instance; use App\Models\Mailbox; use App\Models\MaintenanceWindow; use App\Notifications\CloudReady; +use App\Services\Mail\MailCatalogue; use App\Services\Mail\MailPurpose; use App\Services\Maintenance\MaintenanceNotifier; use App\Support\Settings; @@ -70,9 +71,15 @@ it('names the purpose mailer so the queue worker picks the right transport', fun // `$view->mailer($this->name)->queue(...)` — if MaintenanceNotifier queues // through the bare `Mail` facade instead of `Mail::mailer($mail->mailer)`, // that resolves the DEFAULT mailer ('array' in tests) and overwrites the - // mailable's own 'cp_maintenance' with it BEFORE the queued job is even - // built. The only way to see that is to actually queue it and inspect the - // job that comes out the other side. + // mailable's own mailer with it BEFORE the queued job is even built. The + // only way to see that is to actually queue it and inspect the job that + // comes out the other side. + // + // The name is the MAIL TYPE's mailer, not the purpose's: since the routing + // fix, `mail.route.` moves the SMTP account as well as the From + // address, so each catalogued type has a mailer of its own. Asked through + // MailCatalogue rather than spelled out, so this assertion cannot drift + // from the one list that names them. Mailbox::factory()->create(['key' => 'no-reply', 'address' => 'no-reply@clupilot.com', 'no_reply' => true]); Settings::set(MailPurpose::settingKey(MailPurpose::MAINTENANCE), 'no-reply'); @@ -93,7 +100,7 @@ it('names the purpose mailer so the queue worker picks the right transport', fun // legt ihn unter seinem EXAKTEN Klassennamen ab — ein Oberklassenname // trifft dort nichts. Der Mailer, um den es hier geht, ist unberührt. Queue::assertPushed(PacedMail::class, function (SendQueuedMailable $job) { - return $job->mailable->mailer === 'cp_maintenance'; + return $job->mailable->mailer === MailCatalogue::mailer('maintenance-announcement'); }); }); @@ -107,7 +114,7 @@ it('gives the cancellation mail the same sender as the announcement', function ( ); expect($mail->envelope()->from->address)->toBe('no-reply@clupilot.com') - ->and($mail->mailer)->toBe('cp_maintenance'); + ->and($mail->mailer)->toBe(MailCatalogue::mailer('maintenance-cancelled')); }); it('falls back to the framework default envelope when no mailbox is configured for the purpose', function () { @@ -138,7 +145,7 @@ it('sends the provisioning notification from the provisioning mailbox', function $message = $notification->toMail(new stdClass); expect($message->from[0])->toBe('no-reply@clupilot.com') - ->and($message->mailer)->toBe('cp_provisioning') + ->and($message->mailer)->toBe(MailCatalogue::mailer('cloud-ready')) // no_reply is set, so no Reply-To — the same promise as everywhere else. ->and($message->replyTo)->toBeEmpty(); }); @@ -171,5 +178,5 @@ it("leaves the provisioning notification's sender fields at MailMessage's defaul expect($message->from)->toBeEmpty() ->and($message->replyTo)->toBeEmpty() - ->and($message->mailer)->toBe('cp_provisioning'); + ->and($message->mailer)->toBe(MailCatalogue::mailer('cloud-ready')); }); diff --git a/tests/Feature/MailSenderOwnershipTest.php b/tests/Feature/MailSenderOwnershipTest.php index 2fe97e0..3c2f1e4 100644 --- a/tests/Feature/MailSenderOwnershipTest.php +++ b/tests/Feature/MailSenderOwnershipTest.php @@ -1,5 +1,6 @@ ` moves + * ONE mail type to another mailbox — and it moved only the envelope: the + * mailable still named `cp_`, so `security-block` on info@ went out + * with `From: info@` over no-reply@'s session. The pairing this file guards is + * therefore no longer "From purpose == mailer purpose" but "From key == mailer + * key", with MailCatalogue as the one place that maps a key to its purpose. */ it('gives every mail that takes a mailbox From the matching mailer', function () { $offenders = []; @@ -30,13 +38,52 @@ it('gives every mail that takes a mailbox From the matching mailer', function () } preg_match('/mailbox(?:Envelope|Addresses)\(\s*MailPurpose::([A-Z_]+)/', $code, $from); - preg_match("/->mailer\('cp_'\.MailPurpose::([A-Z_]+)\)/", $code, $mailer); + preg_match("/->mailer\(MailCatalogue::mailer\(('[a-z0-9-]+')/", $code, $keyed); + preg_match("/->mailer\('cp_'\.MailPurpose::([A-Z_]+)\)/", $code, $byPurpose); $fromPurpose = $from[1] ?? null; - $mailerPurpose = $mailer[1] ?? null; - if ($fromPurpose !== null && $fromPurpose !== $mailerPurpose) { - $offenders[] = $file->getFilename().': From '.$fromPurpose.', mailer '.($mailerPurpose ?? 'DEFAULT'); + if ($fromPurpose === null) { + continue; + } + + // The mailer names a mail type: the catalogue must agree that this type + // belongs to the purpose the envelope asks for. Otherwise the fallback + // when no route is set would move the sender without moving the login. + // + // A literal that is not itself a key is a PREFIX — DunningNoticeMail + // builds 'dunning-'.$level, one class for four catalogued types. Then + // every type behind that prefix has to agree, and there has to be at + // least one: a prefix nothing matches is a mailer that does not exist. + if (isset($keyed[1])) { + $literal = trim($keyed[1], "'"); + $expected = constant('App\Services\Mail\MailPurpose::'.$fromPurpose); + + $matching = array_filter( + MailCatalogue::all(), + fn (string $key) => $key === $literal || str_starts_with($key, $literal), + ARRAY_FILTER_USE_KEY, + ); + + if ($matching === []) { + $offenders[] = $file->getFilename().": mail key [{$literal}] matches nothing in MailCatalogue"; + + continue; + } + + foreach ($matching as $key => $entry) { + if ($entry['purpose'] !== $expected) { + $offenders[] = $file->getFilename().": From {$fromPurpose}, mail key {$key} is catalogued as {$entry['purpose']}"; + } + } + + continue; + } + + // No mail key at all (ContactRequestMail): the purpose mailer is still + // the right answer, and it still has to match the From. + if (($byPurpose[1] ?? null) !== $fromPurpose) { + $offenders[] = $file->getFilename().': From '.$fromPurpose.', mailer '.($byPurpose[1] ?? 'DEFAULT'); } } @@ -52,3 +99,12 @@ it('sends each purpose over its own mailer, not over whatever mail.default is', ->toBe(['transport' => 'mailbox', 'purpose' => $purpose]); } }); + +it('gives every catalogued mail type a mailer of its own', function () { + // Without the mailer, MailRoute would move the envelope and leave the SMTP + // session where it was — the exact failure the route was built to allow. + foreach (MailCatalogue::all() as $key => $entry) { + expect(config('mail.mailers.'.MailCatalogue::mailer($key))) + ->toBe(['transport' => 'mailbox', 'purpose' => $entry['purpose'], 'mail_key' => $key]); + } +}); diff --git a/tests/Feature/Security/SecurityBlockMailTest.php b/tests/Feature/Security/SecurityBlockMailTest.php index 1efeba8..2d7c6c5 100644 --- a/tests/Feature/Security/SecurityBlockMailTest.php +++ b/tests/Feature/Security/SecurityBlockMailTest.php @@ -124,6 +124,40 @@ it('schickt bei einer Host-Sperre an jeden Betreiber, der Hosts verwalten darf', Mail::assertNotQueued(SecurityBlockMail::class, fn ($m) => $m->hasTo($darfNicht->email)); }); +it('nennt in der Host-Mail den Host und fuehrt NICHT ins Portal', function () { + // Der Betreiber hat im Portal kein Konto, und Host-Sperren erscheinen dort + // nie — ein Knopf auf portal.security fuehrt ihn also zwangslaeufig ins + // Leere. Und ohne den Hostnamen stuende in einer Meldung an jemanden mit + // einem Dutzend Maschinen nicht, welche gemeint ist. + $host = Host::factory()->active()->create(['name' => 'pve-fsn-07']); + $block = SecurityBlock::factory()->forHost($host)->create(['ip' => '203.0.113.42']); + + $html = (new SecurityBlockMail($block))->render(); + + expect($html)->toContain('pve-fsn-07') + ->and($html)->toContain('203.0.113.42') + ->and($html)->toContain(route('admin.hosts.show', $host)) + ->and($html)->not->toContain(route('portal.security')) + // Und der Kundentext ist weg, nicht bloss ergaenzt. + ->and($html)->not->toContain(__('security.mail_intro', ['ip' => $block->ip, 'attempts' => $block->attempts])); + + expect((new SecurityBlockMail($block))->envelope()->subject) + ->toBe(__('security.mail_subject_host', ['host' => 'pve-fsn-07'])); +}); + +it('laesst die Instanz-Mail unveraendert im Portal landen', function () { + // Die Gegenprobe: der neue Host-Zweig darf den bestehenden Fall nicht + // mitnehmen. + $instance = Instance::factory()->create(['status' => 'active', 'vmid' => 101]); + $block = SecurityBlock::factory()->create(['instance_id' => $instance->id, 'host_id' => null]); + + $html = (new SecurityBlockMail($block))->render(); + + expect($html)->toContain(route('portal.security')) + ->and($html)->toContain(__('security.mail_action')) + ->and($html)->not->toContain(__('security.mail_action_host')); +}); + it('sperrt auch dann, wenn es keinen zustaendigen Betreiber gibt', function () { Mail::fake(); // Keine Betreiber mit hosts.manage — kein Empfaenger, kein Fehler, aber
{{ __('security.field_host') }}{{ $hostName }}
{{ __('security.field_ip') }}{{ $ip }}
{{ __('security.field_ip') }} {{ $ip }}
{{ __('security.field_when') }} {{ $blockedAt?->local()->isoFormat('LLLL') }}
-

{{ __('security.mail_note') }}

+

{{ $istHost ? __('security.mail_note_host') : __('security.mail_note') }}

- {{ __('security.mail_action') }} + {{ $istHost ? __('security.mail_action_host') : __('security.mail_action') }}
-

{{ __('security.mail_false_alarm') }}

+

{{ $istHost ? __('security.mail_false_alarm_host') : __('security.mail_false_alarm') }}