Zwei Empfaengerkreise, und eine Wegwahl, die auch das Konto verschiebt

Fix-Welle nach dem Gesamt-Review, Punkte 4 und 5. Beide sitzen in denselben
Dateien, deshalb ein Commit.

DIE HOST-MELDUNG WAR WORTWOERTLICH DIE KUNDENMAIL (Punkt 4).
notifyHostManagers() verschickte dieselbe SecurityBlockMail. Deren Text ist fuer
den Kunden geschrieben ("bei Ihrer Cloud", "…koennen Sie die Sperre auf der
Sicherheitsuebersicht aufheben"), der Knopf zeigte auf portal.security — wo ein
Betreiber kein Konto hat und Host-Sperren nie erscheinen —, welcher Host
betroffen ist stand nirgends, und die Anrede blieb leer, weil $block->instance
null ist.

Die Mail kann jetzt beide Faelle. Unterscheidungsmerkmal in der Ansicht ist
$hostName: gesetzt heisst Host-Sperre, null heisst Instanz-Sperre. Ein zweites
Flag daneben waere eine zweite Wahrheit.

- Betreff nennt den Host.
- Eigener Textblock (mail_heading_host, mail_preheader_host, mail_intro_host mit
  "fehlgeschlagene SSH-Anmeldungen am Host :host"), eigene Tabellenzeile
  field_host, eigener Hinweis mail_note_host.
- Der Knopf zeigt auf admin.hosts.show, also auf die Host-Detailseite der
  Konsole, wo die Host-Sperren mit ihrem Aufheben-Knopf stehen.
- Keine Anrede im Host-Fall — sie war vorher leer, und der Empfaengerkreis ist
  ohnehin mehrere Personen.
- Der Fehlalarm-Satz ist ein anderer und sagt, was ein Betreiber wissen will:
  bestehende Verbindungen bleiben unberuehrt, die Regel steht unter
  "established,related accept".

R19: die zwei Kommentare, die fuer Host-Sperren Unwahres behaupteten, sind
richtiggestellt — der Kopfkommentar von SecurityBlockMail und der
MailCatalogue-Eintrag. Beide beschreiben jetzt zwei Empfaengerkreise. Ein
Kommentar mit falscher Begruendung haelt den Naechsten vom Nachsehen ab.

DIE WEGWAHL VERSCHOB DEN ABSENDER, NICHT DAS KONTO (Punkt 5).
SecurityBlockMail nagelte mailer('cp_system') fest und holte das Postfach ueber
die Wegwahl. MailboxTransport loeste weiter ueber den ZWECK auf und
authentifizierte mit dessen Zugangsdaten. Legte der Betreiber security-block auf
info@ — der ausdrueckliche Anlass fuer Abschnitt 3a der Spec —, ging die Mail
mit From: info@ durch die SMTP-Sitzung von no-reply@. Viele Server weisen das ab
("553 5.7.1 Sender address rejected: not owned by user"), und dann verschwindet
ausgerechnet die Mail, die er gerade umgelegt hat. Betraf ALLE Mailarten.

Die Wegwahl bestimmt jetzt auch den Mailer. Die zweitbeste Loesung — die Auswahl
in der Konsole auf Postfaecher zu begrenzen, deren Konto benutzt wird — war
nicht noetig.

- config/mail.php erzeugt AUS MailCatalogue::all() je Mailart einen Mailer
  cp_mail_<mailart> mit transport, purpose und mail_key. Erzeugt statt
  aufgelistet, weil der Katalog ausdruecklich die EINE Liste ist; eine zweite
  hier waere die, die beim siebzehnten Mail vergessen wird. Genauso statisch wie
  die fuenf Zweck-Mailer: ein reines Array, keine Datenbank, auch unter
  config:cache unveraendert.
- MailboxTransport nimmt den mail_key entgegen und loest damit ueber DIESELBE
  MailRoute::purposeOrMailbox() auf, die auch der Umschlag fragt. Ohne
  Schluessel entscheidet wie bisher allein der Zweck — jeder Mailer, der keinen
  hat, verhaelt sich unveraendert.
- Jede Mailart mit Schluessel benennt ihren Mailer ueber
  MailCatalogue::mailer(). Dessen Rueckfall auf cp_<zweck> fuer einen
  unbekannten Schluessel ist nicht Zierde: DunningNoticeMail baut seinen
  Schluessel aus der Mahnstufe, und ohne den Rueckfall waere eine Stufe
  ausserhalb 0-3 ein "Mailer [...] is not defined" und damit ein Totalausfall
  statt eines falschen Absenders.
- Betrifft alle Mailarten: 13 Mailables, die CloudReady-Notification und die
  cloud-ready-Vorschau. ContactRequestMail bleibt auf cp_support — es hat keinen
  Schluessel im Katalog, weil es keine eigene Mailart ist.

MailSenderOwnershipTest ist mitgezogen und dabei STRENGER geworden. Die alte
Fassung verglich "From-Zweck == Mailer-Zweck"; jetzt prueft sie, dass der
Katalog dem Zweck zustimmt, den der Umschlag verlangt. Katalog-Drift war bis
hierher nur als Warnung im Docblock von MailCatalogue festgehalten und durch
nichts erzwungen. Ein Literal, das selbst kein Schluessel ist, wird als PRAEFIX
behandelt (der Mahnlauf) — dann muessen alle Schluessel dahinter zustimmen, und
es muss mindestens einen geben.

MailPreviewTest fragte den Zweck bisher, indem es 'cp_' vom Mailer-Namen
abschnitt. Das war eine Zusicherung ueber das Namensschema, nicht ueber die
Zusage; sie liest den Zweck jetzt aus der Konfiguration des Mailers selbst.

tests/Feature/Mail/SenderAddressTest.php gehoert inhaltlich zur parallel
laufenden Sitzung. Vier Zusicherungen darin pruefen genau den Mailer-Namen, den
dieser Commit aendert, also sind sie mitgezogen — die Datei war dabei sauber,
es steckt keine fremde unfertige Arbeit in diesem Commit.

Committet mit ausdruecklicher Dateiangabe am Zeilenende, weil eine parallele
Sitzung an derselben Ablage arbeitet und der Index fremde Arbeit enthalten kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 17:00:20 +02:00
parent 6ebbaa82aa
commit f40b762413
27 changed files with 407 additions and 63 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'),
]);
}
}

View File

@ -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}]."
);
}

View File

@ -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

View File

@ -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);

View File

@ -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

View File

@ -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);
}
}

View File

@ -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

View File

@ -1,5 +1,37 @@
<?php
use App\Services\Mail\MailCatalogue;
/*
| Ein Mailer je MAILART, aus derselben einen Liste erzeugt, aus der auch die
| Vorschau und die Wegwahl lesen (MailCatalogue).
|
| Warum es die zusätzlich zu den fünf Zweck-Mailern unten gibt: die Wegwahl
| (`mail.route.<mailart>`) 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,
],
/*

View File

@ -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',

View File

@ -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',

View File

@ -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)
<x-mail.layout
:heading="__('security.mail_heading')"
:preheader="__('security.mail_preheader', ['ip' => $ip])"
:greeting="$name !== '' ? __('security.mail_greeting', ['name' => $name]) : null"
:heading="$istHost ? __('security.mail_heading_host') : __('security.mail_heading')"
:preheader="$istHost ? __('security.mail_preheader_host', ['ip' => $ip, 'host' => $hostName]) : __('security.mail_preheader', ['ip' => $ip])"
:greeting="! $istHost && $name !== '' ? __('security.mail_greeting', ['name' => $name]) : null"
>
<tr><td style="padding:0 24px 24px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('security.mail_intro', ['ip' => $ip, 'attempts' => $attempts]) }}</p>
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ $istHost ? __('security.mail_intro_host', ['ip' => $ip, 'attempts' => $attempts, 'host' => $hostName]) : __('security.mail_intro', ['ip' => $ip, 'attempts' => $attempts]) }}</p>
</td></tr>
{{-- Die Fakten, als Tabelle statt als Satz: eine Sperre wird nachgeschlagen,
nicht gelesen wie eine Geschichte. --}}
<tr><td style="padding:0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
@if ($istHost)
<tr>
<td style="padding:14px 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;width:38%;">{{ __('security.field_host') }}</td>
<td style="padding:14px 16px 6px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#17171c;font-weight:600;">{{ $hostName }}</td>
</tr>
<tr>
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;width:38%;">{{ __('security.field_ip') }}</td>
<td style="padding:0 16px 6px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#17171c;font-weight:600;">{{ $ip }}</td>
</tr>
@else
<tr>
<td style="padding:14px 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;width:38%;">{{ __('security.field_ip') }}</td>
<td style="padding:14px 16px 6px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#17171c;font-weight:600;">{{ $ip }}</td>
</tr>
@endif
<tr>
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('security.field_when') }}</td>
<td style="padding:0 16px 6px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#43434e;">{{ $blockedAt?->local()->isoFormat('LLLL') }}</td>
@ -32,10 +49,10 @@
</td></tr>
<tr><td style="padding:24px 24px 0 24px;">
<p style="margin:0 0 16px 0;font-size:15px;line-height:24px;color:#43434e;">{{ __('security.mail_note') }}</p>
<p style="margin:0 0 16px 0;font-size:15px;line-height:24px;color:#43434e;">{{ $istHost ? __('security.mail_note_host') : __('security.mail_note') }}</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
<a href="{{ $securityUrl }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('security.mail_action') }}</a>
<a href="{{ $securityUrl }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ $istHost ? __('security.mail_action_host') : __('security.mail_action') }}</a>
</td></tr>
</table>
</td></tr>
@ -43,7 +60,7 @@
<tr><td style="padding:24px 24px 32px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('security.mail_false_alarm') }}</p>
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ $istHost ? __('security.mail_false_alarm_host') : __('security.mail_false_alarm') }}</p>
</td></tr>
</table>
</td></tr>

View File

@ -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_<mailart>), 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');
}
});

View File

@ -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.

View File

@ -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.<mailart>` 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'));
});

View File

@ -1,5 +1,6 @@
<?php
use App\Services\Mail\MailCatalogue;
use Illuminate\Support\Facades\File;
/**
@ -16,6 +17,13 @@ use Illuminate\Support\Facades\File;
* and OrderConfirmationMail both addressed billing@ while going out over the
* default mailer's no-reply@ login, which means no customer had ever received an
* invoice mail or an order confirmation on a server that checks.
*
* The same gap opened a second time, one level up. `mail.route.<mailart>` moves
* ONE mail type to another mailbox and it moved only the envelope: the
* mailable still named `cp_<purpose>`, 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]);
}
});

View File

@ -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