A self-hoster's Gotify or ntfy on a private network was refused at
dial time with no operator override: both senders pinned the public
resolver, and the two grants that exist for other surfaces were never
read here. Add the third grant, NOTIFICATION_PRIVATE_ORIGINS, a comma-
separated list of exact origins read from the server environment.
A listed origin runs under the operator-approved pin: still resolved
and pinned inside the connector, redirects forbidden, loopback and
metadata answers refused. An origin not listed keeps the public pin it
always had. A malformed entry is logged once and grants nothing, and
the valid entries beside it keep working, so a typo in one relay's
origin cannot take every public webhook down with it. There is no
`=true`, CIDR, wildcard or suffix form.
One function, evaluateNotificationTarget, gives the verdict for both
senders, so the test button and the scheduled reminder cannot disagree.
A refusal now carries its own reason (`*_private_origin_refused`) and
an errorCode instead of collapsing into a network error, and a send
that used the grant marks the wide event with
`notification.egress.private_origin` and the channel plus origin.
The pin inventory keeps both senders in the conditional shape and
refuses a bare true or false in either.
Refs #947
Add WEBHOOK and EMAIL to the channel set, the dispatcher cascade, the
per-event default policy, the push-attempt ledger union, and the admin
diagnostic config-presence check. Channels are string-typed, so no
migration is needed. The dispatcher routes each new type through its
sender with the same decrypt + parse + classify path the existing
channels use; the cascade orders webhook + email between the self-host
relays and the Web Push fallback.
Add the webhook + email channel config shapes and their settings-form
validation schemas, plus the query-key entries for the two new config
reads.
v1.4.15 Phase B3, criteria 1 + 2 (atomic — they share migration + retry-
policy types). Until this release the dispatcher fired channels best-
effort and kept retrying every 60s on a 410 Gone — that meant a stale
Web-Push subscription never recovered, a Telegram bot whose chat was
deleted got the same treatment, and the user had no visibility into
WHY their phone had stopped buzzing.
Reliability rules now:
- HARD reject (HTTP 410 web-push / ntfy, Telegram "chat not found" /
"blocked by the user") → channel auto-disabled, disabled_reason
captured, audit log notification.channel.auto_disabled written.
Sender NEVER retried until the user clicks "Re-enable" in Settings.
- SOFT reject (5xx, 429, network timeout) → exponential backoff:
[30s, 5min, 30min, 2h]. Counter consecutive_failures increments
per failure; after the 5th in-a-row the channel is auto-disabled
with reason give_up_after_5_failures.
- COOLDOWN window (next_retry_at > now) → dispatcher skips the
channel without calling the sender — defeats the API-quota-burn
loop a flapping upstream would otherwise create.
Implementation:
- Migration 0028 adds 6 columns to notification_channels (all
nullable / zero-default → no-op for existing rows).
- src/lib/notifications/retry-policy.ts: classifiers + frozen
BACKOFF_SCHEDULE_MS + nextRetryAt() helpers.
- src/lib/notifications/channel-state.ts: only writer of the new
columns (recordChannelSuccess, recordChannelHardReject,
recordChannelTransientFailure, reEnableChannel,
isChannelInCooldown). Each transient/hard path writes audit.
- Senders return a structured SendOutcome instead of bare booleans.
- Dispatcher: cooldown-skip → success → hard-reject → transient/
give-up. Sender-throws absorbed as transient failures.
Tests: +20 unit tests (retry-policy.test.ts, channel-state.test.ts,
dispatcher.test.ts). 883/883 unit tests pass; typecheck clean; lint
clean (no new warnings).
Pre-existing bug fix: src/app/api/settings/telegram/test/route.ts
had if (!ok) against a SendMessageResult object — always truthy,
so the route never reported test-send failures. Now reads result.ok.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>