feat(notifications): polish — digests, prefs, deep-links (plans 24/33)

This commit is contained in:
Juan Denis committed 2026-07-07 20:29:29 -04:00
1 parent c96a476a74
commit 45942339eb
13 files changed
+1155 -28

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
"""Organization-level chat / webhook connection for the Notification Bus.
A shared destination — a Discord/Slack/Telegram room or a generic webhook — that
receives notifications matching its category filter. Replaces the legacy global
``notifications.json`` chat config and the per-user webhook URL fields (which
become deprecated). Credentials are Fernet-encrypted per-field in
``credentials_json`` (never serialized); follows the ``EmailProviderConnection``
Connections pattern.
Per kind, ``credentials_json`` holds:
- discord / slack / webhook : {'url': ..., 'secret': <optional HMAC secret>}
- telegram : {'chat_id': ..., 'bot_token': ...}
"""
import json
from datetime import datetime
from app import db
from app.utils.crypto import decrypt_secret_safe
class ChatWebhookConnection(db.Model):
__tablename__ = 'chat_webhook_connections'
KINDS = ('discord', 'slack', 'telegram', 'webhook')
id = db.Column(db.Integer, primary_key=True)
kind = db.Column(db.String(20), nullable=False) # discord|slack|telegram|webhook
name = db.Column(db.String(120), nullable=False)
# Encrypted per-field credential map (see module docstring).
credentials_json = db.Column(db.Text)
# JSON list of categories this connection accepts (system/security/backups/
# apps). Null / empty = every category (a catch-all room).
categories_json = db.Column(db.Text)
is_active = db.Column(db.Boolean, default=True, nullable=False)
# The default connection for its kind (used when nothing category-matches).
is_default = db.Column(db.Boolean, default=False, index=True)
# True when created by the one-time import of legacy notifications.json config.
imported = db.Column(db.Boolean, default=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
last_tested_at = db.Column(db.DateTime)
last_test_ok = db.Column(db.Boolean)
def raw_credentials(self):
try:
return json.loads(self.credentials_json) if self.credentials_json else {}
except (json.JSONDecodeError, TypeError):
return {}
def credentials(self):
"""Decrypted credentials for use at send/test time (never serialized)."""
out = {}
for key, value in self.raw_credentials().items():
out[key] = decrypt_secret_safe(value) if isinstance(value, str) else value
return out
def categories(self):
try:
value = json.loads(self.categories_json) if self.categories_json else []
return value if isinstance(value, list) else []
except (json.JSONDecodeError, TypeError):
return []
def matches_category(self, category):
"""A connection with no category filter accepts everything."""
cats = self.categories()
return not cats or category in cats
@property
def has_signing(self):
return bool(self.credentials().get('secret')) and self.kind == 'webhook'
def to_dict(self):
creds = self.credentials()
# Surface a masked destination hint, never the full URL / secret / token.
dest = creds.get('url') or creds.get('chat_id') or ''
masked = (dest[:24] + '…') if len(dest) > 25 else dest
return {
'id': self.id,
'kind': self.kind,
'name': self.name,
'destination': masked,
'categories': self.categories(),
'is_active': self.is_active,
'is_default': self.is_default,
'imported': self.imported,
'has_signing': self.has_signing,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_tested_at': self.last_tested_at.isoformat() if self.last_tested_at else None,
'last_test_ok': self.last_test_ok,
}
def __repr__(self):
return f'<ChatWebhookConnection {self.id} {self.kind} {self.name}>'
+41
View File
@@ -0,0 +1,41 @@
"""Deep-link helpers for notifications (plan 24).
A notification's ``action_path`` is stored relative (e.g. ``/domains``) so a
route move never breaks old rows. For channels that need an absolute URL (email,
chat) we resolve it against the panel's canonical domain at render time.
"""
import logging
logger = logging.getLogger(__name__)
def panel_base_url():
"""Absolute base URL of the panel, or '' when no canonical domain is set."""
try:
from app.services.settings_service import SettingsService
domain = (SettingsService.get('canonical_domain', '') or '').strip()
if not domain:
return ''
scheme = 'https' if SettingsService.get('canonical_https_enabled', False) else 'http'
return f'{scheme}://{domain}'
except Exception: # pragma: no cover - settings unavailable
return ''
def absolute_url(path):
"""Turn an ``action_path`` into an absolute URL. Returns None for a falsy
path; returns the path unchanged if already absolute or if no base is set."""
if not path:
return None
if path.startswith('http://') or path.startswith('https://'):
return path
base = panel_base_url()
if not base:
return path # relative fallback — the in-app bell is the primary surface
return base.rstrip('/') + '/' + path.lstrip('/')
def manage_url():
"""The 'manage notifications' footer link, absolute when possible."""
resolved = absolute_url('/settings/notifications')
return resolved if (resolved and resolved.startswith('http')) else None
+13
View File
@@ -46,6 +46,13 @@ class Notification(db.Model):
# (e.g. 'admins', 'user:42', 'ops@example.com'). Not used for routing.
audience = db.Column(db.String(255))
# Deep link to this notification's subject (plan 24 Phase 5). Stored RELATIVE
# (e.g. '/domains') and computed at send time — from the catalog link builder
# or a producer override — so a later route move never breaks old rows. The
# bell/history navigate here; email/chat resolve it to an absolute URL.
action_path = db.Column(db.String(512))
action_label = db.Column(db.String(120))
# Correlation ID for grouping this notification with related telemetry events.
correlation_id = db.Column(db.String(64), nullable=True, index=True)
@@ -76,6 +83,8 @@ class Notification(db.Model):
'title': self.title,
'body': self.body,
'audience': self.audience,
'action_path': self.action_path,
'action_label': self.action_label,
'correlation_id': self.correlation_id,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
@@ -95,6 +104,10 @@ class NotificationDelivery(db.Model):
STATUS_SENT = 'sent' # handed to the channel successfully
STATUS_FAILED = 'failed' # exhausted retries (dead-letter on the queue)
STATUS_SKIPPED = 'skipped' # intentionally not sent (e.g. no target)
# Held for the periodic digest (plan 24 Phase 3): a digestable email or a
# quiet-hours catch-up. Never enqueued; the digest job groups these, sends
# ONE branded email, then flips them to STATUS_SENT.
STATUS_QUEUED_DIGEST = 'queued_digest'
# Channels
CHANNEL_INAPP = 'inapp'
@@ -0,0 +1,52 @@
"""Notification preference depth — per-event channel overrides (plan 24 Phase 2).
Adds ``events_json`` to ``notification_preferences``: a JSON map
``{"<event_key>": {"<channel>": bool}}`` that is the top of the preference
resolution order (event override > category/severity > org default > catalog).
Back-compat: rows without the column default to ``{}`` (no overrides), so
existing users are unaffected.
Org-level defaults live in the ``system_settings`` key ``notify.defaults`` (no
schema change) — see SettingsService.
Idempotent: MigrationService runs _fix_missing_columns() + db.create_all() on
boot before Alembic, so guard on the live schema.
Revision ID: 059_notification_prefs_events
Revises: 058_backup_restore_drills
Create Date: 2026-07-05
"""
from alembic import op
import sqlalchemy as sa
revision = '059_notification_prefs_events'
down_revision = '058_backup_restore_drills'
branch_labels = None
depends_on = None
def _cols(inspector, table):
return {c['name'] for c in inspector.get_columns(table)}
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = set(inspector.get_table_names())
if 'notification_preferences' in tables:
cols = _cols(inspector, 'notification_preferences')
if 'events_json' not in cols:
op.add_column('notification_preferences', sa.Column(
'events_json', sa.Text(), nullable=True, server_default='{}'))
def downgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = set(inspector.get_table_names())
if 'notification_preferences' in tables:
cols = _cols(inspector, 'notification_preferences')
if 'events_json' in cols:
op.drop_column('notification_preferences', 'events_json')
@@ -0,0 +1,56 @@
"""Org-level chat/webhook connections (plan 24 Phase 4).
New ``chat_webhook_connections`` table: a shared Discord/Slack/Telegram room or
generic webhook that receives notifications matching its category filter.
Credentials are Fernet-encrypted per-field in ``credentials_json``. Replaces the
legacy global notifications.json chat config (imported once at boot) and the
deprecated per-user webhook URL fields.
Idempotent: MigrationService runs _fix_missing_columns() + db.create_all() on
boot before Alembic, so guard on the live schema.
Revision ID: 061_chat_webhook_connections
Revises: 060_notification_digest
Create Date: 2026-07-05
"""
from alembic import op
import sqlalchemy as sa
revision = '061_chat_webhook_connections'
down_revision = '060_notification_digest'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'chat_webhook_connections' in set(inspector.get_table_names()):
return
op.create_table(
'chat_webhook_connections',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('kind', sa.String(length=20), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('credentials_json', sa.Text(), nullable=True),
sa.Column('categories_json', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column('is_default', sa.Boolean(), nullable=True, server_default=sa.false()),
sa.Column('imported', sa.Boolean(), nullable=True, server_default=sa.false()),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('last_tested_at', sa.DateTime(), nullable=True),
sa.Column('last_test_ok', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['users.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_chat_webhook_connections_is_default',
'chat_webhook_connections', ['is_default'])
def downgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'chat_webhook_connections' in set(inspector.get_table_names()):
op.drop_table('chat_webhook_connections')
@@ -0,0 +1,191 @@
"""Org-level chat/webhook connections (plan 24 Phase 4).
Proves connection CRUD + encryption, category-matched routing (the bus fans a
notification out to matching connections), HMAC signing of the generic webhook
payload, the consumer delivery path, and legacy-import idempotency.
"""
import hashlib
import hmac
import json
import pytest
from werkzeug.security import generate_password_hash
from app import db
from app.models import User
from app.models.chat_webhook import ChatWebhookConnection
from app.models.notification_preferences import NotificationPreferences
from app.notifications.consumer import process_message
from app.notifications.models import NotificationDelivery
from app.notifications.service import GROUP_SLUG, QUEUE_SLUG, NotificationBusService
from app.queue_bus.service import QueueBusService
from app.services.chat_webhook_service import ChatWebhookService
@pytest.fixture(autouse=True)
def reset_broker(app):
QueueBusService.reset_broker()
def _make_user(username='alice', email='alice@example.com'):
user = User(email=email, username=username,
password_hash=generate_password_hash('x'), role='developer', is_active=True)
db.session.add(user)
db.session.commit()
# Enable everything so per-user gating never hides the org fan-out under test.
prefs = NotificationPreferences.get_or_create(user.id)
prefs.set_severities(['critical', 'warning', 'info', 'success'])
db.session.commit()
return user
def _drain():
msgs = QueueBusService.receive(GROUP_SLUG, QUEUE_SLUG, visibility_timeout_ms=60000, max_messages=100)
for m in msgs:
process_message(m)
return len(msgs)
class TestCrud:
def test_add_encrypts_and_first_is_default(self, app):
conn = ChatWebhookService.add({
'kind': 'webhook', 'name': 'Ops', 'url': 'https://hooks.example/abc',
'secret': 'shh', 'categories': ['security'],
})
assert conn.is_default is True
# URL + secret encrypted at rest, decrypt back, never serialized.
assert conn.raw_credentials()['url'] != 'https://hooks.example/abc'
assert conn.credentials()['url'] == 'https://hooks.example/abc'
assert 'shh' not in json.dumps(conn.to_dict())
assert conn.has_signing is True
def test_add_requires_destination(self, app):
with pytest.raises(ValueError):
ChatWebhookService.add({'kind': 'webhook', 'name': 'x'})
def test_delete_promotes_new_default(self, app):
a = ChatWebhookService.add({'kind': 'webhook', 'name': 'A', 'url': 'https://x/a'})
b = ChatWebhookService.add({'kind': 'webhook', 'name': 'B', 'url': 'https://x/b'})
assert a.is_default and not b.is_default
ChatWebhookService.delete(a.id)
db.session.refresh(b)
assert b.is_default is True
class TestCategoryRouting:
def test_catch_all_matches_every_category(self, app):
conn = ChatWebhookService.add({'kind': 'webhook', 'name': 'All', 'url': 'https://x/all'})
assert conn.matches_category('backups')
assert ChatWebhookService.active_for_category('security') == [conn]
def test_filtered_connection_only_matches_its_categories(self, app):
ChatWebhookService.add({'kind': 'webhook', 'name': 'Sec', 'url': 'https://x/s',
'categories': ['security']})
assert [c.name for c in ChatWebhookService.active_for_category('security')] == ['Sec']
assert ChatWebhookService.active_for_category('backups') == []
class TestBusFanout:
def test_matching_category_gets_a_connection_delivery(self, app):
user = _make_user()
conn = ChatWebhookService.add({'kind': 'webhook', 'name': 'Sec', 'url': 'https://x/s',
'categories': ['security']})
NotificationBusService.send('security.alert', to=user, data={'message': 'breach'})
chat = NotificationDelivery.query.filter_by(channel='webhook', target=f'conn:{conn.id}').all()
assert len(chat) == 1
assert chat[0].recipient_user_id is None # org-level, not per-user
def test_non_matching_category_is_skipped(self, app):
user = _make_user()
ChatWebhookService.add({'kind': 'webhook', 'name': 'Sec', 'url': 'https://x/s',
'categories': ['security']})
NotificationBusService.send('backup.completed', to=user, data={'app': 'blog'})
assert NotificationDelivery.query.filter_by(channel='webhook').count() == 0
def test_directed_send_skips_org_chat(self, app):
user = _make_user()
ChatWebhookService.add({'kind': 'webhook', 'name': 'All', 'url': 'https://x/all'})
# Explicit channels = transactional; org chat must not fan out.
NotificationBusService.send('user.welcome', to=user, channels=['email'], data={})
assert NotificationDelivery.query.filter_by(channel='webhook').count() == 0
class TestSigningAndDelivery:
def test_generic_webhook_signs_and_delivers(self, app, monkeypatch):
captured = {}
class _Resp:
ok = True
status_code = 200
def fake_post(url, data=None, headers=None, timeout=None, **kw):
captured.update({'url': url, 'data': data, 'headers': headers})
return _Resp()
monkeypatch.setattr('app.services.chat_webhook_service.requests.post', fake_post)
user = _make_user()
conn = ChatWebhookService.add({'kind': 'webhook', 'name': 'Ops', 'url': 'https://hooks/x',
'secret': 'topsecret', 'categories': ['security']})
NotificationBusService.send('security.alert', to=user, data={'message': 'breach'})
assert _drain() >= 1
assert captured['url'] == 'https://hooks/x'
body = captured['data']
expected = 'sha256=' + hmac.new(b'topsecret', body, hashlib.sha256).hexdigest()
assert captured['headers']['X-ServerKit-Signature'] == expected
payload = json.loads(body)
assert payload['event'] == 'security.alert'
assert payload['category'] == 'security'
delivery = NotificationDelivery.query.filter_by(target=f'conn:{conn.id}').one()
assert delivery.status == 'sent'
def test_discord_connection_delegates_to_formatter(self, app, monkeypatch):
from app.services.notification_service import NotificationService
seen = {}
monkeypatch.setattr(NotificationService, 'send_discord',
classmethod(lambda cls, alerts, cfg: seen.update({'cfg': cfg}) or {'success': True}))
user = _make_user()
ChatWebhookService.add({'kind': 'discord', 'name': 'Room',
'url': 'https://discord/webhook'})
NotificationBusService.send('backup.completed', to=user, data={'app': 'blog'})
assert _drain() >= 1
assert seen['cfg']['webhook_url'] == 'https://discord/webhook'
class TestImport:
def test_import_is_idempotent(self, app, monkeypatch):
from app.services.notification_service import NotificationService
cfg = {
'discord': {'webhook_url': 'https://discord/hook'},
'slack': {'webhook_url': 'https://hooks.slack/x'},
'generic_webhook': {'url': 'https://hooks/generic'},
'telegram': {},
}
monkeypatch.setattr(NotificationService, 'get_config', classmethod(lambda cls: cfg))
assert ChatWebhookService.import_legacy() == 3
assert ChatWebhookService.import_legacy() == 0 # second run is a no-op
kinds = {c.kind for c in ChatWebhookConnection.query.filter_by(imported=True).all()}
assert kinds == {'discord', 'slack', 'webhook'}
class TestApi:
def test_crud_roundtrip(self, app, client, auth_headers):
resp = client.post('/api/v1/notifications/admin/chat-connections',
json={'kind': 'webhook', 'name': 'Ops', 'url': 'https://x/ops',
'categories': ['security']}, headers=auth_headers)
assert resp.status_code == 201
cid = resp.get_json()['connection']['id']
listing = client.get('/api/v1/notifications/admin/chat-connections', headers=auth_headers)
assert listing.status_code == 200
body = listing.get_json()
assert any(c['id'] == cid for c in body['connections'])
assert 'webhook' in body['kinds']
dele = client.delete(f'/api/v1/notifications/admin/chat-connections/{cid}', headers=auth_headers)
assert dele.status_code == 200
@@ -0,0 +1,126 @@
"""Deep links + history filters (plan 24 Phase 5).
Proves the action_path is computed at send time and persisted (so a route move
never breaks old rows), producer overrides win, the inbox category/severity
filters work, and mark-group-read only touches one category.
"""
import pytest
from werkzeug.security import generate_password_hash
from app import db
from app.models import User
from app.models.notification_preferences import NotificationPreferences
from app.notifications import catalog
from app.notifications.models import Notification
from app.notifications.service import NotificationBusService
from app.queue_bus.service import QueueBusService
@pytest.fixture(autouse=True)
def reset_broker(app):
QueueBusService.reset_broker()
def _make_user(username='alice', email='alice@example.com'):
user = User(email=email, username=username,
password_hash=generate_password_hash('x'), role='developer', is_active=True)
db.session.add(user)
db.session.commit()
prefs = NotificationPreferences.get_or_create(user.id)
prefs.set_severities(['critical', 'warning', 'info', 'success'])
db.session.commit()
return user
class TestLinkBuilder:
def test_catalog_link_persisted_at_send(self, app):
user = _make_user()
res = NotificationBusService.send('dns.unresolved', to=user,
data={'count': 2}, severity='warning')
notif = Notification.query.get(res['notification_id'])
assert notif.action_path == '/domains'
assert notif.action_label
def test_producer_override_wins(self, app):
user = _make_user()
res = NotificationBusService.send('backup.completed', to=user, data={'app': 'blog'},
action_path='/backups?policy=7', action_label='Open policy')
notif = Notification.query.get(res['notification_id'])
assert notif.action_path == '/backups?policy=7'
assert notif.action_label == 'Open policy'
def test_link_survives_a_catalog_route_change(self, app):
# A row keeps the path it was sent with even if the catalog builder later
# points elsewhere — old rows never break.
user = _make_user()
res = NotificationBusService.send('cron.job_failed', to=user, data={'name': 'nightly'})
notif = Notification.query.get(res['notification_id'])
assert notif.action_path == '/cron'
original = catalog._LINKS['cron.job_failed']
catalog._LINKS['cron.job_failed'] = '/scheduled-tasks'
try:
db.session.refresh(notif)
assert notif.action_path == '/cron' # persisted, not recomputed
finally:
catalog._LINKS['cron.job_failed'] = original
def test_unknown_event_has_no_link(self, app):
user = _make_user()
res = NotificationBusService.send('totally.unknown', to=user, data={})
notif = Notification.query.get(res['notification_id'])
assert notif.action_path is None
class TestHistoryFilters:
def _seed(self, user):
NotificationBusService.send('backup.completed', to=user, data={'app': 'a'}, severity='success')
NotificationBusService.send('security.alert', to=user, data={'message': 'x'}, severity='critical')
NotificationBusService.send('system.alert', to=user, data={'message': 'y'}, severity='warning')
def test_category_filter(self, app):
user = _make_user()
self._seed(user)
items = NotificationBusService.inbox(user.id, category='security')
assert len(items) == 1
assert items[0]['category'] == 'security'
def test_severity_filter(self, app):
user = _make_user()
self._seed(user)
items = NotificationBusService.inbox(user.id, severity='critical')
assert all(i['severity'] == 'critical' for i in items)
assert len(items) == 1
def test_inbox_items_carry_action_path(self, app):
user = _make_user()
NotificationBusService.send('dns.unresolved', to=user, data={'count': 1}, severity='warning')
items = NotificationBusService.inbox(user.id)
assert items[0]['action_path'] == '/domains'
def test_mark_group_read_only_touches_category(self, app):
user = _make_user()
self._seed(user)
assert NotificationBusService.unread_count(user.id) == 3
marked = NotificationBusService.mark_all_read(user.id, category='security')
assert marked == 1
assert NotificationBusService.unread_count(user.id) == 2
class TestApi:
def test_inbox_filter_params(self, app, client, auth_headers):
admin = User.query.filter_by(username='testadmin').first()
prefs = NotificationPreferences.get_or_create(admin.id)
prefs.set_severities(['critical', 'warning', 'info', 'success'])
db.session.commit()
NotificationBusService.send('security.alert', to=admin, data={'message': 'x'}, severity='critical')
NotificationBusService.send('backup.completed', to=admin, data={'app': 'a'}, severity='success')
resp = client.get('/api/v1/notifications/inbox?category=security', headers=auth_headers)
assert resp.status_code == 200
items = resp.get_json()['items']
assert all(i['category'] == 'security' for i in items)
grp = client.post('/api/v1/notifications/inbox/read-all?category=security', headers=auth_headers)
assert grp.status_code == 200
assert grp.get_json()['updated'] >= 1
+166
View File
@@ -0,0 +1,166 @@
# The ServerKit notification delivery contract
*One page every producer and plugin author reads before calling
`notify.send()`. It defines who hears about an event, on which channel, and
when. The code cites this file as the source of truth — when the code and this
document disagree, it's a bug in one of them, so fix both together.*
> This document is **tracked** (committed). Planning and status notes live in the
> git-ignored `docs/NOTIFICATIONS_ROADMAP.md`; the resolution/digest rules that
> the bus actually enforces live here.
---
## Preference resolution order
For a given `(user, event, channel)`, the bus (`app/notifications/service.py`,
`_plan()` → `_resolve_channels()`) resolves "does this channel fire?" as a
two-stage decision.
### Stage A — does the event reach this user at all?
Evaluated top-down; the first rule that applies wins:
1. **Account-wide kill switch** — `NotificationPreferences.enabled == False`
stops **everything**, including `critical` and a per-event override. Nothing
below runs.
2. **Per-event force-ON override** — if `events_json[event_key]` turns **any**
channel ON, the event is **resurrected**: it reaches the user even when the
category opt-out or severity list below would exclude it. A per-event
override is the most specific preference a user can express, so it wins over
the coarser category/severity gates (plan 33 Decision 1). A pure force-**OFF**
map (all channels `false`) is *not* a resurrect — it only trims channels the
normal path would have fired.
3. **Category opt-out** — `categories[event.category] == False` stops the event,
*including a critical* (only a force-ON override at rule 2, or the event not
being opted out, lets it through).
4. **Severity gate** — the event's severity must be in `severities`. `critical`
always passes this gate.
If Stage A lets the event through, Stage B decides the channel set.
### Stage B — which channels fire?
Per channel, first match wins (`_resolve_channels()`):
1. **User per-event override** — `events_json[event_key][channel]` (`true` /
`false`). Explicit on/off for this event + channel.
2. **User channel selection** — `channels` (the in-app bell is always on for an
enabled user, independent of this list).
3. **Org default floor** — `notify.defaults[category][channel]` (admin-set): a
per-category channel floor, e.g. "backups → email off for everyone".
4. **Catalog default** — on if the channel is user-selected.
**Hard invariant:** `severity == 'critical'` reaches an enabled, non-category-
opted-out user on **every** configured channel — it pierces the severity gate,
quiet hours, the org floor, and a per-event force-**OFF**. Only the account-wide
`enabled=False` (Stage A rule 1) or a category opt-out (Stage A rule 3) stops a
critical.
---
## Quiet hours
Quiet hours **delay** non-critical notifications; they never delete them
(plan 33 Decision 3).
- **Email** — a non-critical email suppressed by quiet hours is written
`queued_digest` and flushed by the hourly digest job once the user is past
their quiet window (its catch-up; see below).
- **Chat** (Discord / Slack / Telegram / webhook) — a non-critical chat delivery
suppressed by quiet hours is written `queued_digest` too, then flushed as **one
compact per-connection summary** when the window ends. No per-item replay spam:
one summary per destination per quiet window.
- **In-app** — always recorded immediately (the bell is silent), so there's a
catch-up record when the user returns.
- **Critical** — pierces quiet hours on every channel, immediately.
---
## Digest severity rules
Digesting batches low-value events into one branded email instead of N sends.
Per severity, the **default** routing is:
| Severity | In-app | Email |
|---|---|---|
| `info` / `success` | immediate | **digested** |
| `warning` | immediate | **digested** |
| `critical` | immediate | immediate (never digested) |
- Users pick a **digest cadence** (`daily` \| `weekly` \| `off`) in their prefs.
`off` (default) = the pre-digest behavior (email sends immediately).
- A digestable email delivery is written `queued_digest`; the consumer skips it;
the hourly `notifications.digest.run` job groups a user's `queued_digest` rows,
renders one `digest.html` grouped by category, sends it, and marks the rows
`sent` with the digest's provider message-id.
- **UX guards:** an empty digest never sends; a single-item digest uses that
item's own subject line (no "1 update" shame mail).
---
## Deep links
Every notification carries an optional `action_path` (+ `action_label`),
computed **at send time** (catalog link builder or a producer override) and
persisted on the `Notification` row so a later route change never breaks old
rows. Bell, history, and the email CTA all consume the same field.
Deep links follow a **fragment contract** (plan 33 Decision 2): a link is a bare
page path plus an optional `?focus=<kind>:<id>` query param
(e.g. `/backups?focus=policy:12`, `/domains?focus=domain:example.com`,
`/cron?focus=job:job_2026...`). The destination page reads `focus` once on mount
and opens its existing drawer / panel / modal, then clears the param so a refresh
doesn't re-trigger.
---
## Per-user vs org chat targeting
Both apply and are **additive**, deduped per destination (plan 33 Decision 5):
- **Per-user chat** — `_target_for` resolves a user's personal Discord webhook /
Telegram chat id from their prefs; delivered per recipient.
- **Org broadcast** — every active `ChatWebhookConnection` whose category filter
matches receives the event once (`conn:<id>` target), independent of the
per-user recipients.
A `(user_id, channel, target)` / `(None, kind, conn:<id>)` de-dupe key prevents a
re-plan from double-sending to the same destination.
---
## Bounce / complaint handling
Provider bounce/complaint webhooks post to
`POST /api/v1/notifications/inbound/email` (HMAC-signed; 404 when unconfigured).
A hard bounce or complaint is recorded against the delivery's stored
provider message-id; after **N consecutive hard bounces** an address is
auto-muted (with an unmute affordance in the user's notification settings) and
admins are notified (plan 33 Decision 4 / roadmap #24).
### Pointing a provider's bounce webhook at ServerKit
1. **Set the shared secret.** Store a random secret under the SettingsService
key `notify.inbound_secret` (until it's set, the endpoint 404s — the feature
is off by default). Any panel process reading the same DB sees it.
2. **Configure the provider webhook** to `POST` bounce/complaint events to
`https://<your-panel>/api/v1/notifications/inbound/email`, signing the **raw
request body** with HMAC-SHA256 using the secret and sending the hex digest
in an `X-ServerKit-Signature: sha256=<hex>` header. (If your provider signs
with its own scheme, put a tiny signing proxy in front, or extend the one
adapter seam.)
3. **Payload shapes** are mapped provider-agnostically — SendGrid event arrays,
Postmark, Amazon SES (raw or SNS-wrapped), Mailgun, and a generic
`{message_id, kind, reason, email}` all work. Soft/transient failures are
ignored; only hard bounces and spam complaints count toward the mute.
4. **Correlation** is by the provider message-id captured at send time; when a
payload also carries the recipient address, that's used as a fallback.
---
## Throttling
Throttling stays **code-side at the producer** (edge-triggered / first-failure +
recovery, per the doctor/health precedents). The bus adds no generic cooldown
engine.
+10 -2
View File
@@ -45,13 +45,18 @@ export default function NotificationBell() {
};
const onItemClick = (item) => {
// System notices route to their fix; bus notifications just mark read.
// System notices route to their fix; bus notifications mark read and
// then deep-link to their subject when they carry an action_path.
if (item.kind === 'notice') {
setOpen(false);
if (item.action_path) navigate(item.action_path);
return;
}
if (!item.read && markRead) markRead(item.delivery_id);
if (item.action_path) {
setOpen(false);
navigate(item.action_path);
}
};
const badge = unreadCount > 99 ? '99+' : unreadCount;
@@ -130,7 +135,10 @@ export default function NotificationBell() {
<span className="sk-notif__content">
<span className="sk-notif__title">{item.title}</span>
{item.body && <span className="sk-notif__text">{item.body}</span>}
<span className="sk-notif__time">{timeAgo(item.created_at)}</span>
<span className="sk-notif__time">
{timeAgo(item.created_at)}
{item.action_path && <span className="sk-notif__cta"> · {item.action_label || 'Open'} →</span>}
</span>
</span>
</button>
)
+68 -13
View File
@@ -18,12 +18,29 @@ const SEVERITY_DOT = {
const PAGE_SIZE = 25;
const CATEGORY_CHIPS = [
{ key: '', label: 'All categories' },
{ key: 'system', label: 'System' },
{ key: 'security', label: 'Security' },
{ key: 'backups', label: 'Backups' },
{ key: 'apps', label: 'Apps' },
];
const SEVERITY_CHIPS = [
{ key: '', label: 'Any' },
{ key: 'critical', label: 'Critical' },
{ key: 'warning', label: 'Warning' },
{ key: 'success', label: 'Success' },
{ key: 'info', label: 'Info' },
];
export default function Notifications() {
const navigate = useNavigate();
const { isAdmin } = useAuth();
const { refresh: refreshBell, items: ctxItems = [], dismissNotice } = useNotifications() || {};
const [items, setItems] = useState([]);
const [unreadOnly, setUnreadOnly] = useState(false);
const [category, setCategory] = useState('');
const [severity, setSeverity] = useState('');
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
@@ -31,7 +48,10 @@ export default function Notifications() {
const fetchPage = useCallback(async (startOffset, replace) => {
setLoading(true);
try {
const data = await api.getInbox({ limit: PAGE_SIZE, offset: startOffset, unread: unreadOnly });
const data = await api.getInbox({
limit: PAGE_SIZE, offset: startOffset, unread: unreadOnly,
category: category || undefined, severity: severity || undefined,
});
const fresh = data.items || [];
setItems((prev) => (replace ? fresh : [...prev, ...fresh]));
setUnreadCount(data.unread_count || 0);
@@ -41,25 +61,30 @@ export default function Notifications() {
} finally {
setLoading(false);
}
}, [unreadOnly]);
}, [unreadOnly, category, severity]);
useEffect(() => { fetchPage(0, true); }, [fetchPage]);
const onItemClick = async (item) => {
if (item.read) return;
setItems((prev) => prev.map((it) => (
it.delivery_id === item.delivery_id ? { ...it, read: true } : it
)));
setUnreadCount((c) => Math.max(0, c - 1));
try { await api.markNotificationRead(item.delivery_id); } catch { /* reconciled on reload */ }
if (refreshBell) refreshBell();
if (!item.read) {
setItems((prev) => prev.map((it) => (
it.delivery_id === item.delivery_id ? { ...it, read: true } : it
)));
setUnreadCount((c) => Math.max(0, c - 1));
try { await api.markNotificationRead(item.delivery_id); } catch { /* reconciled on reload */ }
if (refreshBell) refreshBell();
}
// Deep link to the notification's subject (whole-row click).
if (item.action_path) navigate(item.action_path);
};
const onMarkAll = async () => {
// When a category filter is active, mark just that group read.
setItems((prev) => prev.map((it) => ({ ...it, read: true })));
setUnreadCount(0);
try { await api.markAllNotificationsRead(); } catch { /* reconciled on reload */ }
setUnreadCount((c) => (category ? Math.max(0, c - items.filter((it) => !it.read).length) : 0));
try { await api.markAllNotificationsRead(category || null); } catch { /* reconciled on reload */ }
if (refreshBell) refreshBell();
fetchPage(0, true);
};
// Live system notices (admin config hints) come from the shared context so they
@@ -80,7 +105,7 @@ export default function Notifications() {
</Button>
)}
<Button variant="outline" size="sm" onClick={onMarkAll} disabled={!unreadCount}>
<CheckCheck size={15} /> Mark all read
<CheckCheck size={15} /> {category ? `Mark ${category} read` : 'Mark all read'}
</Button>
</>
)}
@@ -108,6 +133,33 @@ export default function Notifications() {
</button>
</div>
<div className="sk-notif-page__chips">
<div className="sk-notif-chipset" aria-label="Filter by category">
{CATEGORY_CHIPS.map((c) => (
<button
type="button"
key={c.key || 'all'}
className={`sk-notif-chip${category === c.key ? ' is-active' : ''}`}
onClick={() => setCategory(c.key)}
>
{c.label}
</button>
))}
</div>
<div className="sk-notif-chipset" aria-label="Filter by severity">
{SEVERITY_CHIPS.map((s) => (
<button
type="button"
key={s.key || 'any'}
className={`sk-notif-chip sk-notif-chip--sev${severity === s.key ? ' is-active' : ''}`}
onClick={() => setSeverity(s.key)}
>
{s.label}
</button>
))}
</div>
</div>
{loading && items.length === 0 && noticeItems.length === 0 ? (
<div className="sk-notif-page__state">Loading…</div>
) : items.length === 0 && noticeItems.length === 0 ? (
@@ -148,7 +200,7 @@ export default function Notifications() {
{items.map((item) => (
<li
key={item.delivery_id}
className={`sk-notif-row${item.read ? '' : ' is-unread'}`}
className={`sk-notif-row${item.read ? '' : ' is-unread'}${item.action_path ? ' is-linked' : ''}`}
onClick={() => onItemClick(item)}
>
<span
@@ -159,6 +211,9 @@ export default function Notifications() {
<div className="sk-notif-row__body">
<div className="sk-notif-row__title">{item.title}</div>
{item.body && <div className="sk-notif-row__text">{item.body}</div>}
{item.action_path && (
<div className="sk-notif-row__action">{item.action_label || 'Open'} →</div>
)}
</div>
<span className="sk-notif-row__time">{timeAgo(item.created_at)}</span>
</li>
+54 -2
View File
@@ -8,6 +8,8 @@ export async function getInbox(params = {}) {
if (params.limit) query.append('limit', params.limit);
if (params.offset) query.append('offset', params.offset);
if (params.unread) query.append('unread', '1');
if (params.category) query.append('category', params.category);
if (params.severity) query.append('severity', params.severity);
const suffix = query.toString() ? `?${query}` : '';
return this.request(`/notifications/inbox${suffix}`);
}
@@ -20,8 +22,58 @@ export async function markNotificationRead(deliveryId) {
return this.request(`/notifications/inbox/${deliveryId}/read`, { method: 'POST' });
}
export async function markAllNotificationsRead() {
return this.request('/notifications/inbox/read-all', { method: 'POST' });
export async function markAllNotificationsRead(category = null) {
const suffix = category ? `?category=${encodeURIComponent(category)}` : '';
return this.request(`/notifications/inbox/read-all${suffix}`, { method: 'POST' });
}
// --- Event catalog + org defaults (preference depth) ---
export async function getNotificationCatalog() {
return this.request('/notifications/catalog');
}
export async function getOrgNotificationDefaults() {
return this.request('/notifications/admin/defaults');
}
export async function updateOrgNotificationDefaults(defaults) {
return this.request('/notifications/admin/defaults', {
method: 'PUT',
body: JSON.stringify({ defaults }),
});
}
// --- Org chat/webhook connections (admin) ---
export async function getChatConnections() {
return this.request('/notifications/admin/chat-connections');
}
export async function addChatConnection(data) {
return this.request('/notifications/admin/chat-connections', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateChatConnection(connectionId, data) {
return this.request(`/notifications/admin/chat-connections/${connectionId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function testChatConnection(connectionId) {
return this.request(`/notifications/admin/chat-connections/${connectionId}/test`, { method: 'POST' });
}
export async function setDefaultChatConnection(connectionId) {
return this.request(`/notifications/admin/chat-connections/${connectionId}/default`, { method: 'POST' });
}
export async function deleteChatConnection(connectionId) {
return this.request(`/notifications/admin/chat-connections/${connectionId}`, { method: 'DELETE' });
}
// --- Delivery log / ops (admin) ---
@@ -53,6 +53,31 @@
gap: $space-4;
}
// "Email bouncing" warning shown in the Email Settings card when a user's
// address is auto-muted after repeated hard bounces (plan 33 Phase 4).
.notif-bounce-warning {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-3;
flex-wrap: wrap;
margin-top: $space-3;
padding: $space-3;
background: $danger-bg;
border: 1px solid $danger-border;
border-radius: $radius-md;
&__body {
display: flex;
align-items: center;
gap: $space-2;
flex-wrap: wrap;
font-size: $font-size-sm;
color: $text-secondary;
min-width: 0;
}
}
.channel-toggles,
.category-toggles {
display: flex;
@@ -270,3 +295,212 @@
padding-top: $space-4;
border-top: 1px solid $border-subtle;
}
// ============================================
// PER-EVENT OVERRIDE MATRIX + ORG DEFAULTS (plan 24 Phase 2)
// ============================================
.notif-event-matrix {
display: flex;
flex-direction: column;
gap: $space-2;
}
.notif-event-category {
border: 1px solid $border-subtle;
border-radius: $radius-md;
overflow: hidden;
&__header {
display: flex;
align-items: center;
gap: $space-3;
width: 100%;
padding: $space-3 $space-4;
background: $bg-card;
border: none;
cursor: pointer;
color: $text-primary;
text-align: left;
&:hover { background: $bg-hover; }
.expand-icon {
margin-left: auto;
transition: transform 0.15s ease;
&.expanded { transform: rotate(180deg); }
}
}
&__name { font-weight: 600; }
&__body {
display: flex;
flex-direction: column;
border-top: 1px solid $border-subtle;
}
}
.notif-event-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-4;
padding: $space-3 $space-4;
flex-wrap: wrap;
&:not(:last-child) { border-bottom: 1px solid $border-subtle; }
&__meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 180px;
}
&__title { color: $text-primary; font-size: 0.9rem; }
&__key {
color: $text-tertiary;
font-size: 0.75rem;
font-family: $font-mono;
}
&__channels {
display: flex;
align-items: center;
gap: $space-2;
flex-wrap: wrap;
}
&__reset {
background: transparent;
border: 1px solid $border-subtle;
border-radius: $radius-sm;
color: $text-secondary;
padding: $space-1 $space-2;
font-size: 0.75rem;
cursor: pointer;
&:hover:not(:disabled) { background: $bg-hover; color: $text-primary; }
&:disabled { opacity: 0.4; cursor: default; }
}
}
.notif-chan-toggle {
display: inline-flex;
align-items: center;
gap: $space-1;
padding: $space-1 $space-2;
border: 1px solid $border-subtle;
border-radius: $radius-sm;
background: $bg-card;
color: $text-secondary;
font-size: 0.75rem;
cursor: pointer;
text-transform: capitalize;
&__state {
font-size: 0.68rem;
font-weight: 600;
opacity: 0.85;
}
&--inherit { border-style: dashed; color: $text-tertiary; }
&--on {
background: $accent-glow;
border-color: $accent-primary;
color: $accent-primary;
}
&--off {
background: fade($danger, 12%);
border-color: fade($danger, 40%);
color: $danger;
}
&:hover { border-color: $border-default; }
}
.notif-org-defaults {
.notif-defaults-grid {
display: flex;
flex-direction: column;
gap: $space-1;
margin: $space-3 0;
overflow-x: auto;
}
.notif-defaults-grid__head,
.notif-defaults-grid__row {
display: grid;
grid-template-columns: 140px repeat(5, minmax(64px, 1fr));
align-items: center;
gap: $space-2;
}
.notif-defaults-grid__head {
padding-bottom: $space-2;
border-bottom: 1px solid $border-subtle;
}
.notif-defaults-grid__chan {
text-align: center;
font-size: 0.75rem;
color: $text-secondary;
text-transform: capitalize;
}
.notif-defaults-grid__row {
padding: $space-2 0;
&:not(:last-child) { border-bottom: 1px solid $border-subtle; }
}
.notif-defaults-grid__cat {
font-size: 0.85rem;
color: $text-primary;
font-weight: 500;
}
.notif-defaults-grid__cell {
display: flex;
justify-content: center;
cursor: pointer;
}
}
// Email digest cadence selector (plan 24 Phase 3)
.notif-digest-options {
display: flex;
gap: $space-3;
flex-wrap: wrap;
margin-top: $space-2;
}
.notif-digest-option {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1 1 140px;
padding: $space-3 $space-4;
background: $bg-card;
border: 1px solid $border-subtle;
border-radius: $radius-md;
cursor: pointer;
text-align: left;
color: $text-secondary;
&:hover { border-color: $border-default; }
&.active {
border-color: $accent-primary;
background: $accent-glow;
color: $accent-primary;
}
&__label { font-weight: 600; font-size: 0.9rem; }
&__hint { font-size: 0.75rem; color: $text-tertiary; }
&.active &__hint { color: $accent-primary; opacity: 0.85; }
}
@@ -134,13 +134,6 @@
margin: 0 auto;
padding: 20px 24px 40px;
&__stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 18px;
}
&__filters {
display: flex;
gap: 16px;
@@ -252,10 +245,6 @@
}
}
@media (max-width: 720px) {
.sk-dlog__stats { grid-template-columns: repeat(2, 1fr); }
}
// Email transport / provider management (sk-eprov).
.sk-eprov {
background: $bg-card;
@@ -392,3 +381,48 @@
@media (max-width: 720px) {
.sk-eprov__grid { grid-template-columns: 1fr; }
}
// ── Deep links + history filters (plan 24 Phase 5) ──
.sk-notif-page__chips {
display: flex;
flex-wrap: wrap;
gap: $space-4;
padding: 0 0 $space-3;
margin-bottom: $space-2;
border-bottom: 1px solid $border-subtle;
}
.sk-notif-chipset {
display: flex;
flex-wrap: wrap;
gap: $space-2;
}
.sk-notif-chip {
padding: 3px 10px;
font-size: $font-size-xs;
border: 1px solid $border-subtle;
border-radius: 999px;
background: $bg-card;
color: $text-secondary;
cursor: pointer;
&:hover { background: $bg-hover; color: $text-primary; }
&.is-active {
border-color: $accent-primary;
background: $accent-glow;
color: $accent-primary;
}
}
.sk-notif-row.is-linked { cursor: pointer; }
.sk-notif-row__action {
margin-top: 3px;
font-size: $font-size-xs;
color: $accent-primary;
font-weight: 500;
}
.sk-notif__cta { color: $accent-primary; }