Second stage of the notification feature set. It provides the first concrete INotificationChannel implementation so the pipeline from #220 can actually deliver something. Depends on #220.
Full design: notification concept
Context
The notification channel type is intentionally not decided yet. Rather than committing the whole feature set to e-mail or a specific push service, the design uses a channel-agnostic abstraction plus one reference implementation. A generic HTTP webhook is the right first channel because it covers the widest range of targets with a single implementation: self-hosted push services (ntfy, Gotify), automation platforms (n8n, Home Assistant, Node-RED), and chat integrations behind a relay. Adding e-mail/SMTP later is a self-contained addition — one channel class, one options subsection, one resolver line — because the per-channel ledger and baseline logic from #220 already handle multiple channels.
Scope
Channel implementation
src/DockerUpdateGuard/Notifications/Channels/WebhookNotificationChannel.cs — singleton, Name = "Webhook", constructor takes IHttpClientFactory, IOptionsMonitor<DockerUpdateGuardOptions> and ILogger<WebhookNotificationChannel>.
IsEnabled reads Notifications.Enabled && Notifications.Webhook.Enabled from IOptionsMonitor.CurrentValue on every access, so configuration reloads take effect without a restart (the reason the channel is a singleton using the named client pattern from Portainer/PortainerClient.cs rather than a typed client with baked-in configuration).
SendAsync builds the request from the current options, applies the optional authorization header (AuthorizationHeaderName / AuthorizationHeaderValue), links a per-request CancellationTokenSource with RequestTimeoutSeconds to the caller's token, POSTs the serialized payload as application/json, and maps the outcome:
| Situation |
Result |
| 2xx response |
ExternalOperationResult<NotificationDeliveryData>.Succeeded(...) with DeliveredAtUtc and HttpStatusCode |
| Non-2xx response |
Failed(...) with the status code (and a truncated response snippet) in the message |
Url missing/blank, or channel disabled |
NotConfigured(...) |
HttpRequestException, timeout, serialization error |
caught → Failed(...) |
The channel never throws across the interface — same convention as IVulnerabilityProvider and every other external integration in the repository.
Payload formats
WebhookPayloadFormat selects the serialization shape. Payload DTOs live under src/DockerUpdateGuard/Notifications/Data/ with the *Data suffix and are serialized with System.Text.Json using camelCase.
Generic (default) — the full document, intended for automation platforms that parse structured data:
{
"source": "DockerUpdateGuard",
"version": 1,
"generatedAtUtc": "2026-07-27T18:00:00Z",
"title": "DockerUpdateGuard: 2 updates, 1 critical vulnerability",
"summary": "Plain-text digest of all items",
"updates": [
{
"kind": "ContainerUpdateAvailable",
"subject": "prod / web-1",
"currentVersion": "nginx:1.27.1",
"recommendedVersion": "1.29.0"
}
],
"vulnerabilities": [
{
"advisoryId": "CVE-2026-1234",
"severity": "Critical",
"package": "openssl",
"subject": "nginx:1.27.1"
}
],
"truncatedItemCount": 0
}
The version field is an explicit payload-schema version so receivers can tolerate future additions.
Ntfy — title, message (the plain-text summary), priority, tags; posted to the configured topic URL. Priority 5 when the digest contains vulnerabilities, otherwise 3; tags chosen per content (e.g. warning/package).
Gotify — title, message, priority; the application token is supplied by the user either in the URL query or via the configurable authorization header, so no Gotify-specific option is needed.
HTTP client and DI
In src/DockerUpdateGuard/ServiceCollectionExtensions.cs:
services.AddHttpClient(WebhookNotificationChannel.HttpClientName)
.AddHttpMessageHandler<TransientHttpRetryHandler>();
services.AddSingleton<WebhookNotificationChannel>();
and inject the channel into NotificationChannelResolver, which returns it when IsEnabled is true. TransientHttpRetryHandler gives the same in-request retry behavior (5xx/408/timeouts) that every other outbound client in the application has; the cross-run outbox retry is a separate layer added in #224.
Testing
src/Tests/DockerUpdateGuard.Tests/WebhookNotificationChannelTests.cs using the existing HTTP fakes (Helper/StubHttpMessageHandler.cs, StaticHttpJsonMessageHandler.cs, SequenceHttpMessageHandler.cs, TimeoutHttpMessageHandler.cs) plus TestOptionsMonitor<DockerUpdateGuardOptions> and TestLogger<T>:
Generic payload contains all fields, correct casing, ISO-8601 UTC timestamp, and the truncated-item count
Ntfy payload maps title/message/priority/tags, priority escalates when vulnerabilities are present
Gotify payload maps title/message/priority
- authorization header applied with the configured name; omitted when the value is null
- 2xx →
Succeeded with status code in NotificationDeliveryData
- 4xx/5xx →
Failed with the status code in the message
- request timeout →
Failed, and the caller's cancellation token still propagates cancellation
- missing/blank
Url → NotConfigured
- plaintext HTTP without
AllowInsecureHttp → NotConfigured
- channel disabled →
IsEnabled false and SendAsync returns NotConfigured without issuing a request
Extend NotificationChannelResolverTests: webhook enabled → returned; webhook disabled → not returned; master switch off → empty list regardless of the webhook flag.
Acceptance criteria
Out of scope
Second stage of the notification feature set. It provides the first concrete
INotificationChannelimplementation so the pipeline from #220 can actually deliver something. Depends on #220.Full design: notification concept
Context
The notification channel type is intentionally not decided yet. Rather than committing the whole feature set to e-mail or a specific push service, the design uses a channel-agnostic abstraction plus one reference implementation. A generic HTTP webhook is the right first channel because it covers the widest range of targets with a single implementation: self-hosted push services (ntfy, Gotify), automation platforms (n8n, Home Assistant, Node-RED), and chat integrations behind a relay. Adding e-mail/SMTP later is a self-contained addition — one channel class, one options subsection, one resolver line — because the per-channel ledger and baseline logic from #220 already handle multiple channels.
Scope
Channel implementation
src/DockerUpdateGuard/Notifications/Channels/WebhookNotificationChannel.cs— singleton,Name = "Webhook", constructor takesIHttpClientFactory,IOptionsMonitor<DockerUpdateGuardOptions>andILogger<WebhookNotificationChannel>.IsEnabledreadsNotifications.Enabled && Notifications.Webhook.EnabledfromIOptionsMonitor.CurrentValueon every access, so configuration reloads take effect without a restart (the reason the channel is a singleton using the named client pattern fromPortainer/PortainerClient.csrather than a typed client with baked-in configuration).SendAsyncbuilds the request from the current options, applies the optional authorization header (AuthorizationHeaderName/AuthorizationHeaderValue), links a per-requestCancellationTokenSourcewithRequestTimeoutSecondsto the caller's token, POSTs the serialized payload asapplication/json, and maps the outcome:ExternalOperationResult<NotificationDeliveryData>.Succeeded(...)withDeliveredAtUtcandHttpStatusCodeFailed(...)with the status code (and a truncated response snippet) in the messageUrlmissing/blank, or channel disabledNotConfigured(...)HttpRequestException, timeout, serialization errorFailed(...)The channel never throws across the interface — same convention as
IVulnerabilityProviderand every other external integration in the repository.Urlis rejected unlessAllowInsecureHttpis true. Startup validation (Notifications 1/6: foundation — data model, options, dispatch skeleton #220) already covers the configured case; the runtime guard covers hot-reloaded configuration.Payload formats
WebhookPayloadFormatselects the serialization shape. Payload DTOs live undersrc/DockerUpdateGuard/Notifications/Data/with the*Datasuffix and are serialized withSystem.Text.Jsonusing camelCase.Generic(default) — the full document, intended for automation platforms that parse structured data:{ "source": "DockerUpdateGuard", "version": 1, "generatedAtUtc": "2026-07-27T18:00:00Z", "title": "DockerUpdateGuard: 2 updates, 1 critical vulnerability", "summary": "Plain-text digest of all items", "updates": [ { "kind": "ContainerUpdateAvailable", "subject": "prod / web-1", "currentVersion": "nginx:1.27.1", "recommendedVersion": "1.29.0" } ], "vulnerabilities": [ { "advisoryId": "CVE-2026-1234", "severity": "Critical", "package": "openssl", "subject": "nginx:1.27.1" } ], "truncatedItemCount": 0 }The
versionfield is an explicit payload-schema version so receivers can tolerate future additions.Ntfy—title,message(the plain-text summary),priority,tags; posted to the configured topic URL. Priority 5 when the digest contains vulnerabilities, otherwise 3; tags chosen per content (e.g.warning/package).Gotify—title,message,priority; the application token is supplied by the user either in the URL query or via the configurable authorization header, so no Gotify-specific option is needed.HTTP client and DI
In
src/DockerUpdateGuard/ServiceCollectionExtensions.cs:and inject the channel into
NotificationChannelResolver, which returns it whenIsEnabledis true.TransientHttpRetryHandlergives the same in-request retry behavior (5xx/408/timeouts) that every other outbound client in the application has; the cross-run outbox retry is a separate layer added in #224.Testing
src/Tests/DockerUpdateGuard.Tests/WebhookNotificationChannelTests.csusing the existing HTTP fakes (Helper/StubHttpMessageHandler.cs,StaticHttpJsonMessageHandler.cs,SequenceHttpMessageHandler.cs,TimeoutHttpMessageHandler.cs) plusTestOptionsMonitor<DockerUpdateGuardOptions>andTestLogger<T>:Genericpayload contains all fields, correct casing, ISO-8601 UTC timestamp, and the truncated-item countNtfypayload maps title/message/priority/tags, priority escalates when vulnerabilities are presentGotifypayload maps title/message/prioritySucceededwith status code inNotificationDeliveryDataFailedwith the status code in the messageFailed, and the caller's cancellation token still propagates cancellationUrl→NotConfiguredAllowInsecureHttp→NotConfiguredIsEnabledfalse andSendAsyncreturnsNotConfiguredwithout issuing a requestExtend
NotificationChannelResolverTests: webhook enabled → returned; webhook disabled → not returned; master switch off → empty list regardless of the webhook flag.Acceptance criteria
ExternalOperationResultstatus — no exception escapesSendAsyncAllowInsecureHttpguard behave as specifiedHttpClientis registered withTransientHttpRetryHandlerand asserted inServiceCollectionExtensionsTestsNotifyPreExistingFindings = trueand a real ntfy/webhook endpoint, a digest arrives (note the result in the PR description)reihitsu-format ./clean, build without new analyzer findings, all tests greenOut of scope