feat(addons): SMTP notification add-on with per-event toggles

Issue 4: an SMTP add-on for e-mail notifications.
- SmtpSetup modal: server (host/port/encryption/user/pass), from/to, and
  individual toggles for the events you want mailed — device offline, low
  battery, automation messages. "Send test e-mail" button. Config stored
  encrypted on the addon row; a runtime mailer is built from it (no .env edits).
- NotificationService: notify(event, …) sends only if SMTP is set up AND that
  event is enabled; send() for the test.
- Triggers: automation "notify" actions e-mail (when enabled);
  notifications:sweep (every 5 min) e-mails on NEW device-offline / low-battery
  with per-condition dedup + battery hysteresis, so it's one mail per event.

6 tests (gating, per-event toggle, config persist + password-keep, sweep dedup).
Suite 82 green, 12/12 tabs clean; card + modal browser-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 10:33:23 +02:00
parent 60030c3f61
commit 675d04e104
13 changed files with 539 additions and 5 deletions

View File

@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Models\Device;
use App\Models\Entity;
use App\Services\NotificationService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
/**
* Sweeps for alert conditions (device offline, low battery) and e-mails the SMTP add-on when a NEW
* one appears. A per-condition cache flag dedups, so you get one mail when it happens not one
* every sweep and the flag clears once the condition resolves (with hysteresis for batteries).
*/
class NotificationsSweepCommand extends Command
{
protected $signature = 'notifications:sweep';
protected $description = 'E-mail on new alert conditions (device offline, low battery).';
public function handle(NotificationService $notifier): int
{
// Nothing to do unless SMTP is configured.
if ($notifier->config() === null) {
return self::SUCCESS;
}
$this->sweepOffline($notifier);
$this->sweepBatteries($notifier);
return self::SUCCESS;
}
private function sweepOffline(NotificationService $notifier): void
{
foreach (Device::where('status', 'active')->where('demo', false)->get() as $device) {
$key = "notified:offline:{$device->id}";
if (! $device->isOnline()) {
if (! Cache::has($key) && $notifier->notify('device_offline',
__('notifications.device_offline_subject', ['name' => $device->name]),
__('notifications.device_offline_body', ['name' => $device->name]))) {
Cache::put($key, true, now()->addDay());
}
} else {
Cache::forget($key);
}
}
}
private function sweepBatteries(NotificationService $notifier): void
{
$batteries = Entity::where('type', 'battery')->with('device', 'state')->get();
foreach ($batteries as $entity) {
if ($entity->device === null || $entity->device->demo) {
continue;
}
$pct = (int) data_get($entity->state, 'state.percent', 100);
$key = "notified:battery:{$entity->id}";
if ($pct > 0 && $pct < 20) {
if (! Cache::has($key) && $notifier->notify('low_battery',
__('notifications.low_battery_subject', ['name' => $entity->device->name]),
__('notifications.low_battery_body', ['name' => $entity->device->name, 'percent' => $pct]))) {
Cache::put($key, true, now()->addDay());
}
} elseif ($pct >= 25) {
Cache::forget($key); // hysteresis so it doesn't flap around the threshold
}
}
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Addon;
use App\Services\AddonService;
use App\Services\NotificationService;
use LivewireUI\Modal\ModalComponent;
class SmtpSetup extends ModalComponent
{
public string $host = '';
public string $port = '587';
public string $encryption = 'tls';
public string $username = '';
public string $password = '';
public string $fromEmail = '';
public string $fromName = 'HomeOS';
public string $toEmail = '';
public bool $evDeviceOffline = true;
public bool $evLowBattery = true;
public bool $evAutomation = true;
public bool $hasStoredPassword = false;
public ?string $flash = null;
public bool $flashOk = true;
public static function modalMaxWidth(): string
{
return '3xl';
}
public function mount(): void
{
$cfg = Addon::where('key', 'smtp')->first()?->config ?? [];
$this->host = $cfg['host'] ?? '';
$this->port = (string) ($cfg['port'] ?? '587');
$this->encryption = $cfg['encryption'] ?? 'tls';
$this->username = $cfg['username'] ?? '';
$this->fromEmail = $cfg['from_email'] ?? '';
$this->fromName = $cfg['from_name'] ?? 'HomeOS';
$this->toEmail = $cfg['to_email'] ?? '';
$this->hasStoredPassword = filled($cfg['password'] ?? null);
$this->evDeviceOffline = (bool) data_get($cfg, 'events.device_offline', true);
$this->evLowBattery = (bool) data_get($cfg, 'events.low_battery', true);
$this->evAutomation = (bool) data_get($cfg, 'events.automation', true);
}
protected function rules(): array
{
return [
'host' => 'required|string|max:190',
'port' => 'required|integer|min:1|max:65535',
'encryption' => 'required|in:,tls,ssl',
'username' => 'nullable|string|max:190',
'password' => $this->hasStoredPassword ? 'nullable|string|max:190' : 'nullable|string|max:190',
'fromEmail' => 'required|email|max:190',
'fromName' => 'nullable|string|max:100',
'toEmail' => 'required|email|max:190',
];
}
private function persist(): void
{
$service = app(AddonService::class);
$addon = $service->install('smtp');
$existing = $addon->config ?? [];
$config = [
'host' => $this->host,
'port' => (int) $this->port,
'encryption' => $this->encryption,
'username' => $this->username,
'password' => $this->password !== '' ? $this->password : ($existing['password'] ?? ''),
'from_email' => $this->fromEmail,
'from_name' => $this->fromName,
'to_email' => $this->toEmail,
'events' => [
'device_offline' => $this->evDeviceOffline,
'low_battery' => $this->evLowBattery,
'automation' => $this->evAutomation,
],
];
$addon->forceFill(['config' => $config, 'status' => 'connected', 'connected_at' => now()])->save();
}
public function sendTest(): void
{
$this->validate();
$this->persist();
try {
$ok = app(NotificationService::class)->send(__('addons.smtp.test_subject'), __('addons.smtp.test_body'));
} catch (\Throwable $e) {
report($e);
$ok = false;
}
$this->flash = $ok ? __('addons.smtp.test_ok') : __('addons.smtp.test_fail');
$this->flashOk = $ok;
}
public function save()
{
$this->validate();
$this->persist();
$this->closeModal();
return redirect()->route('addons.index');
}
public function render()
{
return view('livewire.modals.smtp-setup');
}
}

View File

@ -31,6 +31,17 @@ class AddonRegistry
'docs' => 'https://github.com/tsightler/ring-mqtt',
'setup_modal' => 'modals.ring-setup',
],
'smtp' => [
'key' => 'smtp',
'name' => 'E-Mail (SMTP)',
'vendor' => 'SMTP',
'icon' => 'alert',
'cloud' => false,
'summary_key' => 'addons.smtp.summary',
'body_key' => 'addons.smtp.body',
'docs' => 'https://homeos.local',
'setup_modal' => 'modals.smtp-setup',
],
];
}

View File

@ -131,7 +131,13 @@ class AutomationEngine
$this->commands->setOn($entity, (bool) ($action['on'] ?? true), 'automation');
} elseif ($type === 'notify') {
Log::info('[automation'.($automation->dry_run ? ' dry-run' : '')."] {$automation->name}: ".($action['message'] ?? ''));
$message = $action['message'] ?? '';
Log::info('[automation'.($automation->dry_run ? ' dry-run' : '')."] {$automation->name}: ".$message);
// Also e-mail it if the SMTP add-on is set up and automation notifications are enabled.
if (! $automation->dry_run) {
app(\App\Services\NotificationService::class)->notify('automation', $automation->name, $message);
}
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Services;
use App\Models\Addon;
use Illuminate\Support\Facades\Mail;
/**
* Sends e-mail notifications via the user's SMTP add-on. The SMTP settings live (encrypted) on the
* addon row; we configure a runtime mailer from them so no .env editing is needed. Each event type
* is individually toggleable, so the user controls exactly what they get mailed about.
*/
class NotificationService
{
/** @return array<string,mixed>|null */
public function config(): ?array
{
return Addon::where('key', 'smtp')->where('installed', true)->first()?->config;
}
public function eventEnabled(string $event): bool
{
return (bool) data_get($this->config(), "events.{$event}", false);
}
/** Send only if the SMTP add-on is set up AND this event type is enabled. */
public function notify(string $event, string $subject, string $body): bool
{
if (! $this->eventEnabled($event)) {
return false;
}
return $this->send($subject, $body);
}
/** Send unconditionally (used by the "send test" button). Returns success. */
public function send(string $subject, string $body): bool
{
$cfg = $this->config();
if ($cfg === null || blank($cfg['to_email'] ?? null) || blank($cfg['host'] ?? null)) {
return false;
}
$this->configureMailer($cfg);
Mail::mailer('homeos_smtp')->raw($body, function ($message) use ($cfg) {
$message->to($cfg['to_email'])
->subject('[HomeOS] '.$subject)
->from($cfg['from_email'] ?: 'homeos@localhost', $cfg['from_name'] ?: 'HomeOS');
});
return true;
}
/** @param array<string,mixed> $cfg */
private function configureMailer(array $cfg): void
{
config(['mail.mailers.homeos_smtp' => [
'transport' => 'smtp',
'host' => $cfg['host'],
'port' => (int) ($cfg['port'] ?? 587),
'encryption' => ($cfg['encryption'] ?? '') ?: null,
'username' => ($cfg['username'] ?? '') ?: null,
'password' => ($cfg['password'] ?? '') ?: null,
'timeout' => 10,
]]);
}
}

View File

@ -33,4 +33,31 @@ return [
'status_hint_disconnected' => 'Noch keine Verbindung von der Bridge empfangen. Starte die Bridge und melde dich an.',
'connected_since' => 'Verbunden seit',
],
'smtp' => [
'summary' => 'E-Mail-Benachrichtigungen bei Ereignissen (per SMTP).',
'body' => 'Sende dir E-Mails, wenn ein Gerät offline geht, ein Akku leer wird oder eine Automation etwas meldet — einzeln einstellbar.',
'setup_title'=> 'E-Mail-Benachrichtigungen',
'server' => 'SMTP-Server',
'host' => 'Host',
'port' => 'Port',
'encryption'=> 'Verschlüsselung',
'enc_none' => 'Keine',
'username' => 'Benutzername',
'password' => 'Passwort',
'password_stored' => 'Passwort gespeichert. Leer lassen, um es zu behalten.',
'from_email'=> 'Absender-E-Mail',
'from_name' => 'Absender-Name',
'to_email' => 'Empfänger-E-Mail',
'events_title' => 'Benachrichtigen bei',
'ev_device_offline' => 'Gerät offline',
'ev_low_battery' => 'Akku niedrig',
'ev_automation' => 'Automation-Meldungen',
'test' => 'Test-E-Mail senden',
'test_subject' => 'Testbenachrichtigung',
'test_body' => 'Dies ist eine Test-E-Mail von HomeOS. Wenn du das liest, funktioniert SMTP.',
'test_ok' => 'Test-E-Mail gesendet.',
'test_fail' => 'Senden fehlgeschlagen — Einstellungen prüfen.',
'save' => 'Speichern',
],
];

View File

@ -0,0 +1,8 @@
<?php
return [
'device_offline_subject' => 'Gerät offline: :name',
'device_offline_body' => 'Das Gerät „:name" ist nicht mehr erreichbar.',
'low_battery_subject' => 'Akku niedrig: :name',
'low_battery_body' => 'Der Akku von „:name" ist bei :percent %.',
];

View File

@ -33,4 +33,31 @@ return [
'status_hint_disconnected' => 'No connection received from the bridge yet. Start the bridge and sign in.',
'connected_since' => 'Connected since',
],
'smtp' => [
'summary' => 'E-mail notifications for events (via SMTP).',
'body' => 'Get e-mails when a device goes offline, a battery runs low or an automation reports something — toggle each individually.',
'setup_title'=> 'E-mail notifications',
'server' => 'SMTP server',
'host' => 'Host',
'port' => 'Port',
'encryption'=> 'Encryption',
'enc_none' => 'None',
'username' => 'Username',
'password' => 'Password',
'password_stored' => 'Password stored. Leave blank to keep it.',
'from_email'=> 'From e-mail',
'from_name' => 'From name',
'to_email' => 'Recipient e-mail',
'events_title' => 'Notify on',
'ev_device_offline' => 'Device offline',
'ev_low_battery' => 'Low battery',
'ev_automation' => 'Automation messages',
'test' => 'Send test e-mail',
'test_subject' => 'Test notification',
'test_body' => 'This is a test e-mail from HomeOS. If you can read this, SMTP works.',
'test_ok' => 'Test e-mail sent.',
'test_fail' => 'Sending failed — check the settings.',
'save' => 'Save',
],
];

View File

@ -0,0 +1,8 @@
<?php
return [
'device_offline_subject' => 'Device offline: :name',
'device_offline_body' => 'The device “:name” is no longer reachable.',
'low_battery_subject' => 'Low battery: :name',
'low_battery_body' => 'The battery of “:name” is at :percent%.',
];

View File

@ -0,0 +1,75 @@
<form wire:submit="save">
<x-modal :title="__('addons.smtp.setup_title')" icon="alert">
<div class="p-5 flex flex-col gap-5">
@if ($flash)
<p @class([
'rounded-lg border px-3 py-2 text-[12.5px]',
'border-online/30 bg-online/10 text-online' => $flashOk,
'border-offline/30 bg-offline/10 text-offline' => ! $flashOk,
])>{{ $flash }}</p>
@endif
{{-- Server --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5 sm:col-span-2">
<label class="text-[12px] font-semibold text-ink-2">{{ __('addons.smtp.host') }}</label>
<input wire:model="host" type="text" placeholder="smtp.example.com"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm font-mono text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('host') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-1.5">
<label class="text-[12px] font-semibold text-ink-2">{{ __('addons.smtp.port') }}</label>
<input wire:model="port" type="number" min="1" max="65535"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm font-mono text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('port') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-1.5">
<label class="text-[12px] font-semibold text-ink-2">{{ __('addons.smtp.encryption') }}</label>
<select wire:model="encryption" class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent">
<option value="tls">TLS</option>
<option value="ssl">SSL</option>
<option value="">{{ __('addons.smtp.enc_none') }}</option>
</select>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-[12px] font-semibold text-ink-2">{{ __('addons.smtp.username') }}</label>
<input wire:model="username" type="text" autocomplete="off"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
</div>
<div class="flex flex-col gap-1.5">
<label class="text-[12px] font-semibold text-ink-2">{{ __('addons.smtp.password') }}</label>
<input wire:model="password" type="password" autocomplete="new-password"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@if ($hasStoredPassword) <p class="text-[11px] text-ink-3">{{ __('addons.smtp.password_stored') }}</p> @endif
</div>
<div class="flex flex-col gap-1.5">
<label class="text-[12px] font-semibold text-ink-2">{{ __('addons.smtp.from_email') }}</label>
<input wire:model="fromEmail" type="email"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('fromEmail') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-1.5">
<label class="text-[12px] font-semibold text-ink-2">{{ __('addons.smtp.to_email') }}</label>
<input wire:model="toEmail" type="email"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('toEmail') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
</div>
{{-- Events --}}
<fieldset class="flex flex-col gap-2.5 rounded-xl border border-line-soft p-3.5">
<legend class="px-1.5 text-[11px] font-bold uppercase tracking-wide text-ink-3">{{ __('addons.smtp.events_title') }}</legend>
<label class="flex items-center gap-2.5 cursor-pointer text-[13px] text-ink"><input type="checkbox" wire:model="evDeviceOffline" class="w-4 h-4 rounded border-line text-accent focus:ring-accent"> {{ __('addons.smtp.ev_device_offline') }}</label>
<label class="flex items-center gap-2.5 cursor-pointer text-[13px] text-ink"><input type="checkbox" wire:model="evLowBattery" class="w-4 h-4 rounded border-line text-accent focus:ring-accent"> {{ __('addons.smtp.ev_low_battery') }}</label>
<label class="flex items-center gap-2.5 cursor-pointer text-[13px] text-ink"><input type="checkbox" wire:model="evAutomation" class="w-4 h-4 rounded border-line text-accent focus:ring-accent"> {{ __('addons.smtp.ev_automation') }}</label>
</fieldset>
</div>
<x-slot:footer>
<button type="button" wire:click="sendTest" wire:loading.attr="disabled" wire:target="sendTest"
class="mr-auto rounded-lg border border-line px-3.5 py-2 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('addons.smtp.test') }}</button>
<button type="button" wire:click="closeModal" class="rounded-lg border border-line px-3.5 py-2 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('common.cancel') }}</button>
<button type="submit" class="rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">{{ __('addons.smtp.save') }}</button>
</x-slot:footer>
</x-modal>
</form>

View File

@ -19,3 +19,6 @@ Schedule::command('automations:tick')->everyMinute()->withoutOverlapping();
// Poll local (HTTP) Shelly devices for near-live status (MQTT devices push instead).
Schedule::command('shelly:poll')->everyTenSeconds()->withoutOverlapping();
// E-mail alerts (device offline, low battery) — only acts if the SMTP add-on is set up.
Schedule::command('notifications:sweep')->everyFiveMinutes()->withoutOverlapping();

View File

@ -16,11 +16,10 @@ class AddonTest extends TestCase
public function test_registry_exposes_ring_with_state_defaults(): void
{
$addons = AddonRegistry::withState();
$addons = collect(AddonRegistry::withState());
$this->assertCount(1, $addons);
$ring = $addons[0];
$this->assertSame('ring', $ring['key']);
$ring = $addons->firstWhere('key', 'ring');
$this->assertNotNull($ring);
$this->assertTrue($ring['cloud']);
$this->assertFalse($ring['installed']);
$this->assertSame('disconnected', $ring['status']);

View File

@ -0,0 +1,96 @@
<?php
namespace Tests\Feature;
use App\Livewire\Modals\SmtpSetup;
use App\Models\Addon;
use App\Models\Device;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
use Tests\TestCase;
class NotificationTest extends TestCase
{
use RefreshDatabase;
private function configureSmtp(array $events = ['device_offline' => true, 'low_battery' => true, 'automation' => true]): void
{
Addon::create([
'key' => 'smtp',
'installed' => true,
'status' => 'connected',
'config' => [
'host' => 'smtp.example.com', 'port' => 587, 'encryption' => 'tls',
'username' => 'u', 'password' => 'p', 'from_email' => 'homeos@example.com',
'from_name' => 'HomeOS', 'to_email' => 'me@example.com', 'events' => $events,
],
]);
}
public function test_notify_does_nothing_without_the_smtp_addon(): void
{
Mail::fake();
$this->assertFalse(app(NotificationService::class)->notify('device_offline', 's', 'b'));
}
public function test_notify_respects_the_per_event_toggle(): void
{
Mail::fake();
$this->configureSmtp(['device_offline' => false, 'low_battery' => true, 'automation' => true]);
$this->assertFalse(app(NotificationService::class)->notify('device_offline', 's', 'b'));
$this->assertTrue(app(NotificationService::class)->notify('low_battery', 's', 'b'));
}
public function test_smtp_setup_persists_config_and_keeps_password(): void
{
$this->actingAs(User::factory()->create());
Livewire::test(SmtpSetup::class)
->set('host', 'smtp.gmail.com')->set('port', '587')->set('encryption', 'tls')
->set('username', 'me')->set('password', 'secret')
->set('fromEmail', 'from@x.com')->set('toEmail', 'to@x.com')
->set('evLowBattery', false)
->call('save')
->assertRedirect(route('addons.index'));
$cfg = Addon::where('key', 'smtp')->first()->config;
$this->assertSame('smtp.gmail.com', $cfg['host']);
$this->assertSame('secret', $cfg['password']);
$this->assertFalse($cfg['events']['low_battery']);
// Re-saving with a blank password keeps the stored one.
Livewire::test(SmtpSetup::class)
->set('host', 'smtp.gmail.com')->set('port', '465')->set('encryption', 'ssl')
->set('fromEmail', 'from@x.com')->set('toEmail', 'to@x.com')
->set('password', '')
->call('save');
$this->assertSame('secret', Addon::where('key', 'smtp')->first()->config['password']);
}
public function test_sweep_emails_once_per_offline_device(): void
{
Mail::fake();
$this->configureSmtp();
// Active, not demo, stale last_seen → offline.
$device = Device::create(['name' => 'Lampe', 'vendor' => 'Shelly', 'protocol' => 'http', 'status' => 'active', 'demo' => false, 'last_seen_at' => now()->subHour(), 'config' => ['ip' => '1.2.3.4']]);
$this->artisan('notifications:sweep')->assertSuccessful();
$this->assertTrue(Cache::has("notified:offline:{$device->id}"));
// Second sweep — still flagged, no re-notify (flag unchanged).
$this->artisan('notifications:sweep')->assertSuccessful();
$this->assertTrue(Cache::has("notified:offline:{$device->id}"));
// Back online → flag cleared so a future outage notifies again.
$device->update(['last_seen_at' => now()]);
$this->artisan('notifications:sweep')->assertSuccessful();
$this->assertFalse(Cache::has("notified:offline:{$device->id}"));
}
}