Comprehensive multi-area pass driven by a parallel 8-agent production
review. Frontend, backend, database, security, performance, operational,
plus a new self-monitoring feature.
## Critical fixes
- Planka webhook: reads bounded raw body (was NameError on every call)
- HA quiet hours: ha_state_changed/automation_triggered/service_called/
event_fired added to deferrable set (were silently dropped)
- DNS-rebinding SSRF: PinnedResolver wired into shared aiohttp session
- Telegram inbound webhook: secret now mandatory (401 without)
- Generic webhook: auth_mode="none" requires explicit
acknowledge_unauthenticated=true; per-IP rate limit 60/min
- svelte-check: 5 null-narrowing errors in EventDetailModal fixed
- Provider hardcoding: Immich-only block extracted to descriptor
featureDiscoveryHint
- command_sync: snapshot+expunge bot before exiting AsyncSession
## Bug fixes
- notifier asyncio.gather(return_exceptions=True) — one bad chat no longer
cancels peer sends
- NotificationDispatcher hoisted out of per-tracker loop
- Provider credential resolution unified across all 5 dispatch sites
- HA asyncio.shield now drains inner task on cancellation
- Provider construction switched from if/elif ladder to factory registry
- NUT first poll seeds silently (no spurious ups_on_battery)
- Quiet-hours gate: event-type-disabled now wins over deferral
- APScheduler drain job ID resolution upgraded to seconds
- HA on_status_change wired through to EventLog
- Webhook payload rollback failures now logged (not swallowed)
- Batched receivers/chats/bots in load_link_data (was per-target N+1)
- flag_modified on JSON column reassignments in deferred_dispatch
## Database
- UNIQUE indexes on service_provider.webhook_token,
telegram_bot.webhook_path_id, partial UNIQUE on telegram_bot.bot_id,
telegram_chat(bot_id, chat_id), notification_tracker_target unique link,
partial UNIQUE on bridge_self provider per user
- Composite ix_event_log_user_event_type_created index
- save_chat_from_webhook switched to ON CONFLICT DO UPDATE
- ondelete=CASCADE on user-id FKs (model annotation; app-side cascade
delete added for existing data)
- delete_notification_tracker converted from N+1 to bulk DELETE/UPDATE
- Module-level asyncio.Lock replaced with lazy _get_lock() pattern
- VACUUM INTO snapshot now PRAGMA integrity_check verified
## Performance
- Jinja2 template compilation LRU cached (lru_cache maxsize=512)
- Per-locale render cache in NotificationDispatcher (skips re-rendering
identical content for receivers sharing a locale)
- Tracker list cached per provider_id with 5s TTL + explicit invalidation
on tracker CRUD (relieves HA chat-bus rate query pressure)
- Nav-counts collapsed from 16 round-trips to single UNION ALL
- HA event_log: skip persisting empty assets_added/removed events
## Security hardening
- Mass-assignment guard on Action create/update; cron sub-minute reject
- Backup JSON depth/node-count cap (depth ≤ 10, nodes ≤ 100k)
- _sanitize_config extended to all JSON-typed fields on backup import
- Telegram _safe_get walks redirects manually with SSRF revalidation
- Bcrypt 72-byte password length cap with clear 422
- Webhook payload body redaction; sensitive substring set extended with
oauth/client_secret/webhook_secret/csrf in both header filter and
template extras filter
## Frontend
- 76 catch (err: any) sites converted to errMsg(err) helper
- globalProviderFilter: pure getter; reconciliation moved to one-time
$effect in +layout
- Provider-filter binding: removed paired $effects + _syncingFilter flag,
now one-way derived
- entity-cache: separate _refreshing flag for background re-fetches
- api.ts 401 handling: AuthRedirectError class + dedup _redirecting flag,
goto() instead of window.location.href
- a11y: aria-expanded on mobile More, role=switch + aria-checked on
Telegram bot toggles
## Tests & operations
- CI pytest gate added to .gitea/workflows/build.yml + release.yml
(wheel-built install to dodge editable-install slowness)
- /api/ready upgraded to deep healthcheck (db SELECT 1, scheduler.running,
HA supervisor presence) returning {ready, checks, errors, version}
- /api/metrics endpoint with prometheus_client (deferred_pending,
event_log_total, dispatch_duration, poll_failures, send_failures)
- New OPERATIONS.md covering deploy, healthchecks, metrics, backup/restore
procedures, log handling, common scenarios, upgrade flow
- New tests: test_bridge_self (11), test_gitea_parser (9),
test_planka_parser (6), test_immich_change_detector (6),
test_backup_roundtrip (1)
## New feature: bridge self-monitoring
- New bridge_self provider type — internal sink for bridge health events
- Three event types: bridge_self_poll_failures (consecutive tracker poll
failures), bridge_self_deferred_backlog (pending count crosses
threshold), bridge_self_target_failures (consecutive 5xx/network
failures per target)
- Per-user thresholds (defaults: 3 / 100 / 5) configurable via the
provider config form
- Auto-seeded on user create + /setup + boot backfill for existing users
- Anti-spam: counters reset after emission; backlog uses transition latch
- Self-loop guard: bridge_self failures don't count toward target-failure
thresholds (logged only) — wire to your own Telegram/Email/Matrix to
get notified when polls/dispatches/sends fail
- 6 default templates (3 events × 2 locales), tracking config columns
with backfill migration, frontend descriptor (excluded from "create
provider" wizard since auto-managed)
Operator-visible behavior changes (call out in release notes):
- NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET now REQUIRED for webhook mode
- Existing webhook providers with auth_mode="none" need explicit opt-in
- Generic webhook endpoint rate-limited 60/min per source IP
- HA disconnect/reconnect writes ha_status_* EventLog rows
- Every user gets a bridge_self provider — wire it to a target to
receive failure alerts
Pre-existing test failures (test_ssrf, test_release_provider) on
Python 3.13 are unrelated; CI runs on 3.12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Notify Bridge
A generic bridge between service providers and notification targets.
Notify Bridge monitors services (Immich, Gitea, Planka, NUT, Google Photos, generic webhooks, and internal scheduler) for changes and dispatches notifications to configurable targets (Telegram, Discord, Slack, Matrix, ntfy, email, generic webhooks) using customizable templates.
Architecture
- Service Providers — Connectors to external services (Immich, Gitea, Planka, NUT, Google Photos, generic Webhook, internal Scheduler)
- Trackers — Monitor specific collections within a provider for changes
- Tracking Configs — Define what events to watch for and scheduling rules
- Notification Targets — Where to send notifications (Telegram, Discord, Slack, Matrix, ntfy, email, webhook URLs)
- Template Configs — Jinja2 templates that format notifications per provider type
Project Structure
packages/
core/ — Shared library: providers, models, notifications, templates
server/ — FastAPI REST server with SQLite database
frontend/ — SvelteKit dashboard (Svelte 5, Tailwind CSS v4)
Quick Docker Deploy
docker run -d \
--name notify-bridge \
--restart unless-stopped \
-p 8420:8420 \
-v notify-bridge-data:/data \
-e NOTIFY_BRIDGE_SECRET_KEY=$(openssl rand -hex 32) \
-e NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=http://localhost:8420 \
git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge:latest
Then open http://localhost:8420 in your browser.
Environment Variables
Core settings (all prefixed with NOTIFY_BRIDGE_):
| Variable | Required | Default | Description |
|---|---|---|---|
SECRET_KEY |
Yes | — | Secret for JWT signing (min 32 chars). Default placeholders and known dev-only strings are rejected on startup. |
CORS_ALLOWED_ORIGINS |
Recommended | http://localhost:5175 |
Comma-separated browser origins. Wildcard * is rejected because credentials are enabled. Set this to the URL you load the UI from. |
DATA_DIR |
No | /data (in Docker) |
Directory for SQLite DB, backups, and caches. Mount a volume here. |
DATABASE_URL |
No | sqlite+aiosqlite:///<DATA_DIR>/notify_bridge.db |
Override DB connection string. |
HOST |
No | 0.0.0.0 |
Bind address. |
PORT |
No | 8420 |
Server listen port. |
DEBUG |
No | false |
Enable debug logging. |
Reverse proxy / network:
| Variable | Default | Description |
|---|---|---|
FORWARDED_ALLOW_IPS |
127.0.0.1 |
Trusted proxy IPs whose X-Forwarded-For / X-Forwarded-Proto headers are honored. Set to your reverse proxy IP (e.g. 172.17.0.1 for the default Docker bridge). Use * only when the container is not directly internet-reachable. |
EXTERNAL_URL |
— | Public base URL (e.g. https://notify.example.com). Used to build webhook URLs shown in the UI. Also settable from the Settings page. |
ALLOW_PRIVATE_URLS |
unset | Set to 1 to allow requests to RFC1918 / loopback / link-local hosts (homelab scenario: Immich/Gitea on the same LAN). Do not enable on a publicly exposed instance. |
Auth & tokens:
| Variable | Default | Description |
|---|---|---|
ACCESS_TOKEN_EXPIRE_MINUTES |
15 |
Lifetime of access JWTs. |
REFRESH_TOKEN_EXPIRE_DAYS |
30 |
Lifetime of refresh tokens. |
JWT_ISSUER |
notify-bridge |
iss claim. |
JWT_AUDIENCE |
notify-bridge-api |
aud claim. |
Logging (all are also live-editable in the Settings page, except log_format):
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL |
INFO |
Root level: DEBUG / INFO / WARNING / ERROR. |
LOG_FORMAT |
text |
text or json. Switching requires a restart. |
LOG_LEVELS |
— | Per-module overrides, e.g. notify_bridge_core.notifications.telegram.client=DEBUG,sqlalchemy.engine=INFO. |
Retention & maintenance:
| Variable | Default | Description |
|---|---|---|
EVENT_LOG_RETENTION_DAYS |
30 |
Days of event_log history to keep. 0 disables the retention job. |
PRE_MIGRATE_SNAPSHOT_KEEP |
5 |
Number of pre-migration DB snapshots to keep in <DATA_DIR>/backups/. 0 disables snapshotting. |
GRACEFUL_SHUTDOWN_SECONDS |
60 |
Time to wait for in-flight requests / scheduler jobs on SIGTERM before force-killing. |
Integrations & misc:
| Variable | Default | Description |
|---|---|---|
TELEGRAM_WEBHOOK_SECRET |
— | Shared secret for Telegram bot webhooks. Also settable from the Settings page. |
TIMEZONE |
UTC |
IANA timezone (e.g. Europe/Warsaw) used by the scheduler. Also settable from the Settings page. |
STATIC_DIR |
/app/static (in Docker) |
Frontend static files directory. The Docker image sets this; don't override unless you're running outside the image. |
SUPERVISED |
auto-detect | Set to 1 to tell the backup endpoint that an external supervisor will restart the process. |
Docker Compose
services:
notify-bridge:
image: git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge:latest
container_name: notify-bridge
restart: unless-stopped
ports:
- "8420:8420"
volumes:
- notify-bridge-data:/data
environment:
# REQUIRED — any 32+ byte random string. `openssl rand -hex 32` is one way.
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)}
# Comma-separated list of allowed browser origins. Wildcard `*` is
# rejected on startup because credentials are enabled.
- NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-http://localhost:8420}
# Trusted proxy IPs whose X-Forwarded-For / X-Forwarded-Proto we honor.
# Set this to your reverse proxy's IP (e.g. 172.17.0.1 for the default
# docker bridge, or `*` only if the container is NOT reachable from the
# public internet).
- NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS=${NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS:-127.0.0.1}
# Opt-in SSRF bypass for private/loopback/link-local hosts (homelab
# scenario — tracking an Immich/Gitea instance on the same LAN). DO NOT
# enable on a publicly exposed instance.
# - NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
healthcheck:
# Use /api/ready (not /api/health) so the container is only reported
# healthy after migrations and the scheduler finish booting.
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/ready', timeout=3)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
mem_limit: 512m
cpus: 1.0
pids_limit: 256
volumes:
notify-bridge-data:
A ready-to-use docker-compose.yml lives at the repo root.
Health & Readiness
GET /api/health— process is up. Use for liveness probes.GET /api/ready— migrations + scheduler have booted. Use for readiness probes and DockerHEALTHCHECK(as the compose example above does).
Quick Start (Development)
# Backend
cd packages/server
pip install -e .
NOTIFY_BRIDGE_DATA_DIR=./test-data NOTIFY_BRIDGE_SECRET_KEY=your-secret-key-min-32chars \
python -m uvicorn notify_bridge_server.main:app --host 0.0.0.0 --port 8420
# Frontend
cd frontend
npm install
npm run dev
Supported Providers
- Immich — Photo/video server with album change detection (polling)
- Gitea — Git server with push / issue / PR / release events (webhook)
- Planka — Kanban board with card / list / board events (webhook)
- NUT — Network UPS Tools for battery / power events (polling)
- Google Photos — Album change detection (polling)
- Generic Webhook — Catch arbitrary JSON payloads and route them via templates (webhook)
- Scheduler — Internal provider for time-based scheduled messages
Supported Notification Targets
- Telegram — Bot API with rich formatting, media groups, and inline commands
- Discord — Webhook-based delivery with embeds
- Slack — Incoming webhooks with Block Kit formatting
- Matrix — Homeserver delivery with HTML formatting
- ntfy — Self-hostable push notifications
- Email — SMTP with HTML / plain-text templates
- Generic Webhook — POST custom JSON payloads to any URL
Bot Commands
Telegram bots can serve interactive commands per provider. All commands use Jinja2 templates that you can customize from the Command Templates page.
| Provider | Commands |
|---|---|
| Immich | /status /albums /events /summary /latest /memory /random /search /find /person /place /favorites /people /help |
| Gitea | /status /repos /issues /prs /commits /help |
| Planka | /status /boards /cards /lists /help |
| NUT | /status /devices /battery /help |
| Google Photos | /status /albums /latest /search /random /help |
| Generic Webhook | /status /help |
Every provider also responds to /start, and rate-limit / empty-result
fallback messages are templated as well.
Smart Actions
Beyond notifications, providers can run actions against the source service. Currently implemented:
- Immich — Auto-Organize — Automatically sort newly-detected assets into albums based on configurable rules. Each rule combines criteria (people in the photo, search query, favorites, date range) with a target album, and can create the album if it doesn't exist. Supports dry-run mode for previewing what would move before committing.