- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)
Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
248 lines
12 KiB
PHP
248 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Enums\ActivityType;
|
|
use App\Enums\EmailSuppressionReason;
|
|
use App\Models\ActivityEvent;
|
|
use App\Models\EmailBounce;
|
|
use App\Models\EmailSuppression;
|
|
use App\Services\Mail\EmailSuppressionService;
|
|
use App\Services\Mail\MailCircuitBreaker;
|
|
use Illuminate\Validation\Rule;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\Title;
|
|
use Livewire\Attributes\Url;
|
|
use Livewire\Component;
|
|
use Livewire\WithPagination;
|
|
|
|
/**
|
|
* Admin ▸ Mail-Sperrliste (Roadmap 1b.6): nicht erreichbare Adressen aus
|
|
* Rückläufern, SMTP-Ablehnungen und Import — mit Freigabe, manueller
|
|
* Sperre, den letzten Rückläufern und den nicht lesbaren Mails, die im
|
|
* Postfach unter „Unparsed" auf einen Menschen warten.
|
|
*/
|
|
new #[Layout('components.layouts.app'), Title('Mail-Sperrliste')] class extends Component
|
|
{
|
|
use WithPagination;
|
|
|
|
#[Url]
|
|
public string $search = '';
|
|
|
|
#[Url]
|
|
public string $view = 'active';
|
|
|
|
public string $newEmail = '';
|
|
|
|
public string $newNote = '';
|
|
|
|
public function updatedSearch(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedView(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function release(int $id): void
|
|
{
|
|
$this->authorize('users:manage');
|
|
|
|
$suppression = EmailSuppression::query()->findOrFail($id);
|
|
app(EmailSuppressionService::class)->release($suppression, auth()->user(), 'Freigabe im Admin');
|
|
|
|
session()->flash('status', __(':email freigegeben — der nächste Rückläufer sperrt sie wieder.', ['email' => $suppression->email]));
|
|
}
|
|
|
|
public function suppress(): void
|
|
{
|
|
$this->authorize('users:manage');
|
|
|
|
$validated = $this->validate([
|
|
'newEmail' => ['required', 'email', 'max:190'],
|
|
'newNote' => ['nullable', 'string', 'max:500'],
|
|
]);
|
|
|
|
app(EmailSuppressionService::class)->suppress($validated['newEmail'], EmailSuppressionReason::Manual, 'admin', $validated['newNote'] ?: 'manuell im Admin');
|
|
|
|
$this->reset('newEmail', 'newNote');
|
|
session()->flash('status', __('Adresse gesperrt.'));
|
|
}
|
|
|
|
public function with(): array
|
|
{
|
|
$query = EmailSuppression::query()
|
|
->when($this->view === 'active', fn ($q) => $q->active())
|
|
->when($this->view === 'released', fn ($q) => $q->whereNotNull('released_at'))
|
|
->when(trim($this->search) !== '', fn ($q) => $q->whereLike('email', '%'.trim($this->search).'%'))
|
|
->orderByDesc('suppressed_at')
|
|
->orderByDesc('id');
|
|
|
|
return [
|
|
'suppressions' => $query->paginate(50),
|
|
'activeCount' => EmailSuppression::query()->active()->count(),
|
|
'bouncesThisWeek' => EmailBounce::query()->where('received_at', '>=', now()->subDays(7))->count(),
|
|
'unparsed' => ActivityEvent::query()
|
|
->where('type', ActivityType::MailSuppressed->value)
|
|
->where('description', 'like', 'Rückläufer nicht lesbar%')
|
|
->latest('occurred_at')
|
|
->limit(10)
|
|
->get(),
|
|
'circuitOpenUntil' => app(MailCircuitBreaker::class)->openUntil(),
|
|
'returnPath' => (string) config('bounces.return_path'),
|
|
'imapConfigured' => filled(config('imap.accounts.bounces.host')),
|
|
];
|
|
}
|
|
}; ?>
|
|
|
|
<div class="space-y-8">
|
|
<x-portal.page-header
|
|
:badge="__('Admin Backend')"
|
|
:eyebrow="__('Administration · E-Mail')"
|
|
:title="__('Mail-Sperrliste')"
|
|
:description="__('Adressen, die nicht erreichbar sind: aus Rückläufern (Bounce-Postfach), SMTP-Ablehnungen und der Bounce-Liste. Gesperrte Adressen bekommen keine Mail mehr — Magic Links, Benachrichtigungen, Newsletter — und ihre Newsrooms sind nicht beanspruchbar.')"
|
|
>
|
|
<x-slot:meta>
|
|
@if ($circuitOpenUntil)
|
|
<span class="badge err dot">{{ __('Versand angehalten bis :time', ['time' => $circuitOpenUntil->format('H:i')]) }}</span>
|
|
@else
|
|
<span class="badge ok dot">{{ __('Versand läuft') }}</span>
|
|
@endif
|
|
@if ($imapConfigured)
|
|
<span class="badge hub">{{ __('Postfach angebunden') }}</span>
|
|
@else
|
|
<span class="badge warn">{{ __('Kein Bounce-Postfach konfiguriert') }}</span>
|
|
@endif
|
|
</x-slot:meta>
|
|
</x-portal.page-header>
|
|
|
|
@if (session('status'))
|
|
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
|
|
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
|
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
|
|
{{ session('status') }}
|
|
</div>
|
|
@endif
|
|
|
|
<section class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
<x-portal.stat-card variant="primary" :label="__('Gesperrt')" :value="number_format($activeCount)">
|
|
<x-slot:meta>{{ __('Adressen ohne Zustellung') }}</x-slot:meta>
|
|
</x-portal.stat-card>
|
|
<x-portal.stat-card variant="warn" :label="__('Rückläufer 7 Tage')" :value="number_format($bouncesThisWeek)">
|
|
<x-slot:meta>{{ __('alle Quellen') }}</x-slot:meta>
|
|
</x-portal.stat-card>
|
|
<x-portal.stat-card variant="muted" :label="__('Return-Path')" :value="$returnPath ?: '—'">
|
|
<x-slot:meta>{{ $returnPath ? __('Rückläufer landen im Bounce-Postfach') : __('MAIL_RETURN_PATH nicht gesetzt') }}</x-slot:meta>
|
|
</x-portal.stat-card>
|
|
</section>
|
|
|
|
@if ($unparsed->isNotEmpty())
|
|
<article class="panel">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Nicht lesbare Rückläufer') }}</span>
|
|
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('liegen im Postfach unter „Unparsed“') }}</span>
|
|
</div>
|
|
<ul class="p-5 space-y-2 text-[12.5px]">
|
|
@foreach ($unparsed as $event)
|
|
<li class="flex items-baseline gap-3">
|
|
<span class="font-mono text-[11px] text-[color:var(--color-ink-4)] flex-shrink-0">{{ $event->occurred_at?->format('d.m. H:i') }}</span>
|
|
<span class="text-[color:var(--color-ink-2)]">{{ Str::after($event->description, 'Unparsed“: ') }}</span>
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
</article>
|
|
@endif
|
|
|
|
@can('users:manage')
|
|
<article class="panel">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Adresse sperren') }}</span>
|
|
</div>
|
|
<form wire:submit="suppress" class="p-5 flex flex-col gap-3 sm:flex-row sm:items-end">
|
|
<flux:field class="flex-1">
|
|
<flux:input wire:model="newEmail" type="email" :label="__('E-Mail')" placeholder="presse@beispiel.de" />
|
|
<flux:error name="newEmail" />
|
|
</flux:field>
|
|
<flux:field class="flex-1">
|
|
<flux:input wire:model="newNote" :label="__('Notiz')" placeholder="{{ __('z. B. Rückläufer von Hand geprüft') }}" />
|
|
<flux:error name="newNote" />
|
|
</flux:field>
|
|
<flux:button type="submit" variant="primary" icon="no-symbol">{{ __('Sperren') }}</flux:button>
|
|
</form>
|
|
</article>
|
|
@endcan
|
|
|
|
<article class="panel overflow-hidden">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Adressen') }}</span>
|
|
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __(':count Einträge', ['count' => $suppressions->total()]) }}</span>
|
|
</div>
|
|
<div class="p-5 flex flex-col gap-4 sm:flex-row border-b border-[color:var(--color-bg-rule)]">
|
|
<flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" :placeholder="__('Adresse suchen…')" class="w-full sm:w-80" />
|
|
<flux:select wire:model.live="view" class="w-full sm:w-56">
|
|
<option value="active">{{ __('Gesperrt') }}</option>
|
|
<option value="released">{{ __('Freigegeben') }}</option>
|
|
<option value="all">{{ __('Alle') }}</option>
|
|
</flux:select>
|
|
</div>
|
|
<flux:table>
|
|
<flux:table.columns>
|
|
<flux:table.column>{{ __('Adresse') }}</flux:table.column>
|
|
<flux:table.column>{{ __('Grund') }}</flux:table.column>
|
|
<flux:table.column>{{ __('Rückläufer') }}</flux:table.column>
|
|
<flux:table.column>{{ __('Zuletzt') }}</flux:table.column>
|
|
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
|
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
|
|
</flux:table.columns>
|
|
<flux:table.rows>
|
|
@forelse ($suppressions as $suppression)
|
|
<flux:table.row :key="$suppression->id">
|
|
<flux:table.cell>
|
|
<span class="text-[13px] font-semibold text-[color:var(--color-ink)] break-all">{{ $suppression->email }}</span>
|
|
@if ($suppression->note)
|
|
<span class="block text-[11px] text-[color:var(--color-ink-4)]">{{ $suppression->note }}</span>
|
|
@endif
|
|
</flux:table.cell>
|
|
<flux:table.cell>
|
|
<span class="text-[12.5px] text-[color:var(--color-ink-2)]">{{ $suppression->reason->label() }}</span>
|
|
<span class="block text-[11px] font-mono text-[color:var(--color-ink-4)]">{{ $suppression->source }}</span>
|
|
</flux:table.cell>
|
|
<flux:table.cell>
|
|
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ $suppression->hard_bounce_count }} {{ __('hart') }} · {{ $suppression->soft_bounce_count }} {{ __('weich') }}</span>
|
|
</flux:table.cell>
|
|
<flux:table.cell>
|
|
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ ($suppression->last_bounced_at ?? $suppression->suppressed_at)?->format('d.m.Y H:i') ?? '—' }}</span>
|
|
</flux:table.cell>
|
|
<flux:table.cell>
|
|
@if ($suppression->isActive())
|
|
<span class="badge err dot">{{ __('gesperrt') }}</span>
|
|
@else
|
|
<span class="badge ok dot">{{ __('freigegeben') }}</span>
|
|
@endif
|
|
</flux:table.cell>
|
|
<flux:table.cell>
|
|
@can('users:manage')
|
|
@if ($suppression->isActive())
|
|
<flux:button size="sm" variant="filled" icon="lock-open" wire:click="release({{ $suppression->id }})" wire:confirm="{{ __('Adresse freigeben? Sie bekommt dann wieder Mails, bis der nächste Rückläufer kommt.') }}">
|
|
{{ __('Freigeben') }}
|
|
</flux:button>
|
|
@endif
|
|
@endcan
|
|
</flux:table.cell>
|
|
</flux:table.row>
|
|
@empty
|
|
<flux:table.row>
|
|
<flux:table.cell colspan="6">
|
|
<div class="px-4 py-10 text-center text-[12.5px] text-[color:var(--color-ink-3)]">
|
|
{{ __('Keine Adressen in dieser Ansicht.') }}
|
|
</div>
|
|
</flux:table.cell>
|
|
</flux:table.row>
|
|
@endforelse
|
|
</flux:table.rows>
|
|
</flux:table>
|
|
@if ($suppressions->hasPages())
|
|
<div class="p-4 border-t border-[color:var(--color-bg-rule)]">{{ $suppressions->links() }}</div>
|
|
@endif
|
|
</article>
|
|
</div>
|