From 675d04e104437b1b74d1ba5ab02329c7fff6102a Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Sat, 18 Jul 2026 10:33:23 +0200 Subject: [PATCH] feat(addons): SMTP notification add-on with per-event toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Commands/NotificationsSweepCommand.php | 75 ++++++++++ app/Livewire/Modals/SmtpSetup.php | 131 ++++++++++++++++++ app/Services/AddonRegistry.php | 11 ++ app/Services/AutomationEngine.php | 8 +- app/Services/NotificationService.php | 68 +++++++++ lang/de/addons.php | 27 ++++ lang/de/notifications.php | 8 ++ lang/en/addons.php | 27 ++++ lang/en/notifications.php | 8 ++ .../livewire/modals/smtp-setup.blade.php | 75 ++++++++++ routes/console.php | 3 + tests/Feature/AddonTest.php | 7 +- tests/Feature/NotificationTest.php | 96 +++++++++++++ 13 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 app/Console/Commands/NotificationsSweepCommand.php create mode 100644 app/Livewire/Modals/SmtpSetup.php create mode 100644 app/Services/NotificationService.php create mode 100644 lang/de/notifications.php create mode 100644 lang/en/notifications.php create mode 100644 resources/views/livewire/modals/smtp-setup.blade.php create mode 100644 tests/Feature/NotificationTest.php diff --git a/app/Console/Commands/NotificationsSweepCommand.php b/app/Console/Commands/NotificationsSweepCommand.php new file mode 100644 index 0000000..2c50936 --- /dev/null +++ b/app/Console/Commands/NotificationsSweepCommand.php @@ -0,0 +1,75 @@ +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 + } + } + } +} diff --git a/app/Livewire/Modals/SmtpSetup.php b/app/Livewire/Modals/SmtpSetup.php new file mode 100644 index 0000000..635bc5d --- /dev/null +++ b/app/Livewire/Modals/SmtpSetup.php @@ -0,0 +1,131 @@ +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'); + } +} diff --git a/app/Services/AddonRegistry.php b/app/Services/AddonRegistry.php index f90d7f9..22b7004 100644 --- a/app/Services/AddonRegistry.php +++ b/app/Services/AddonRegistry.php @@ -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', + ], ]; } diff --git a/app/Services/AutomationEngine.php b/app/Services/AutomationEngine.php index 627e7bc..b118d0d 100644 --- a/app/Services/AutomationEngine.php +++ b/app/Services/AutomationEngine.php @@ -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); + } } } diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php new file mode 100644 index 0000000..c01198c --- /dev/null +++ b/app/Services/NotificationService.php @@ -0,0 +1,68 @@ +|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 $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, + ]]); + } +} diff --git a/lang/de/addons.php b/lang/de/addons.php index 85807a6..de5f214 100644 --- a/lang/de/addons.php +++ b/lang/de/addons.php @@ -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', + ], ]; diff --git a/lang/de/notifications.php b/lang/de/notifications.php new file mode 100644 index 0000000..ccffef3 --- /dev/null +++ b/lang/de/notifications.php @@ -0,0 +1,8 @@ + '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 %.', +]; diff --git a/lang/en/addons.php b/lang/en/addons.php index e97d0eb..b4f892c 100644 --- a/lang/en/addons.php +++ b/lang/en/addons.php @@ -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', + ], ]; diff --git a/lang/en/notifications.php b/lang/en/notifications.php new file mode 100644 index 0000000..73c0eac --- /dev/null +++ b/lang/en/notifications.php @@ -0,0 +1,8 @@ + '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%.', +]; diff --git a/resources/views/livewire/modals/smtp-setup.blade.php b/resources/views/livewire/modals/smtp-setup.blade.php new file mode 100644 index 0000000..f5f5674 --- /dev/null +++ b/resources/views/livewire/modals/smtp-setup.blade.php @@ -0,0 +1,75 @@ +
+ +
+ @if ($flash) +

$flashOk, + 'border-offline/30 bg-offline/10 text-offline' => ! $flashOk, + ])>{{ $flash }}

+ @endif + + {{-- Server --}} +
+
+ + + @error('host')

{{ $message }}

@enderror +
+
+ + + @error('port')

{{ $message }}

@enderror +
+
+ + +
+
+ + +
+
+ + + @if ($hasStoredPassword)

{{ __('addons.smtp.password_stored') }}

@endif +
+
+ + + @error('fromEmail')

{{ $message }}

@enderror +
+
+ + + @error('toEmail')

{{ $message }}

@enderror +
+
+ + {{-- Events --}} +
+ {{ __('addons.smtp.events_title') }} + + + +
+
+ + + + + + +
+
diff --git a/routes/console.php b/routes/console.php index 467bb97..628aff6 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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(); diff --git a/tests/Feature/AddonTest.php b/tests/Feature/AddonTest.php index 7f4b067..6ecc0c9 100644 --- a/tests/Feature/AddonTest.php +++ b/tests/Feature/AddonTest.php @@ -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']); diff --git a/tests/Feature/NotificationTest.php b/tests/Feature/NotificationTest.php new file mode 100644 index 0000000..7ef44af --- /dev/null +++ b/tests/Feature/NotificationTest.php @@ -0,0 +1,96 @@ + 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}")); + } +}