Skip to content

Signals & Notifications — Architecture

Audience: Architects, tech leads, senior engineers.


Context and Purpose

The Signals system solves a single problem: delivering proactive notifications to users who are not currently in the Swisper chat interface. Background jobs decide what to notify about; Signals decides how to deliver it.

The architectural goals are:

  • Channel abstraction — Adding a new delivery channel (e.g., email, SMS) requires only a new NotificationChannel implementation, with no changes to callers
  • Parallel delivery — When a user has multiple channels enabled, messages are sent concurrently
  • Two-level preferences — A global signals toggle and per-channel enable flags give users fine-grained control
  • Resilient delivery — Channel failures are isolated; one channel failing does not prevent delivery on others

Architecture Overview

graph TD
    subgraph Jobs["Background Jobs"]
        DB["DailyBriefingJob"]
        EM["SendImportantEmailNotificationsJob"]
        MP["PreMeetingPrepJob"]
        CR["CommitmentReminderJob"]
        AR["AwaitingResponseNotificationJob"]
    end

    subgraph Signals["SignalsService"]
        SS["SignalsService"]
        CM["ChannelManager"]
    end

    subgraph Channels["Notification Channels"]
        TC["TelegramChannel"]
        THC["ThreemaChannel"]
    end

    subgraph Prefs["Preferences"]
        UP["user_preferences.signals_enabled"]
        UI["integrations.notifications_enabled"]
    end

    subgraph External["External APIs"]
        TAPI["Telegram Bot API"]
        THAPI["Threema Gateway"]
    end

    Jobs --> SS
    SS --> CM
    CM --> UP & UI
    SS --> TC & THC
    TC --> TAPI
    THC --> THAPI

Component Responsibilities

Component File Responsibility
SignalsService services/signals/signals_service.py Entry point for all notifications. Checks global signals_enabled, resolves available channels via ChannelManager, sends in parallel. Provides convenience methods like send_email_notification()
ChannelManager services/signals/channel_manager.py Queries UserIntegration for active notification channels with notifications_enabled=True. Separates notification channels from data integrations
TelegramChannel services/signals/notification_channels/telegram_channel.py Formats messages as Markdown v1 (bold titles, escaped special characters), sends via sendMessage endpoint using chat_id from integration config
ThreemaChannel services/signals/notification_channels/threema_channel.py Formats as plain text with emoji prefixes, sends via Threema Gateway HTTPS API. Uses tenacity for retry (3 attempts, exponential backoff on timeout/5xx)
NotificationChannel ABC services/signals/notification_channels/base_channel.py Abstract base class defining send(), get_user_id(), is_notifications_enabled()
Notification Preferences API api/routes/user_notification_preferences.py GET/POST /users/notification-preferences, GET /users/notification-channels, PATCH /users/integrations/{integration_id}/notifications

Data Model

NotificationMessage

The standard payload for all notifications:

Field Type Required Purpose
title str | None No When present, rendered as a bold header (Telegram) or emoji-prefixed header (Threema)
content str Yes Message body
user_id str Yes Target user ID, used to resolve channel addresses
metadata dict | None No Optional extra data (currently unused by channels)

Preference Storage

Preference Location Scope
signals_enabled user_preferences.standard_rules.signals_enabled Global: all channels on/off
notifications_enabled integrations.notifications_enabled Per-channel: individual toggle

Message Formatting

Channel Title present Title absent Special handling
Telegram 🔔 *{title}*\n\n{content} {content} Markdown v1: escapes _, *, `, [ in content
Threema 📧 {title}\n\n{content} {content} Plain text; 3500-char limit is enforced via LLM prompts, not in the channel implementation

Key Design Decisions

Decision: Channel abstraction via ABC

  • Chosen: NotificationChannel ABC with send(), get_user_id(), is_notifications_enabled() methods
  • Rejected: Hard-coded if/else per channel type in the service
  • Rationale: Clean separation of concerns. Each channel owns its formatting, API interaction, and error handling. New channels are added by implementing the ABC and registering in SignalsService.__init__().

Decision: Concurrent delivery to all enabled channels

  • Chosen: asyncio.create_task() per channel, then awaiting each task — channels run concurrently but results are collected individually for per-channel error isolation
  • Rejected: Sequential delivery, preferred-channel-only delivery
  • Rationale: Users who enable multiple channels expect to receive notifications on all of them. Concurrent delivery minimizes latency. Failures on one channel do not block others, and per-task await enables per-channel success/failure tracking in the results dict.

Decision: Global + per-channel preference model

  • Chosen: Two-level toggle: signals_enabled (global) + notifications_enabled (per integration)
  • Rejected: Single global toggle, per-notification-type preferences
  • Rationale: The global toggle is a quick "mute all" control. Per-channel enables let users keep Telegram on but Threema off (or vice versa). Per-notification-type preferences (e.g., disable meeting prep but keep email alerts) are not yet implemented but the model can be extended.

Interfaces and Contracts

Interface Direction Consumer Contract
SignalsService.send_notification(msg) Inbound Background jobs Accepts NotificationMessage, delivers to all enabled channels
SignalsService.send_email_notification(user_id, subject, sender_email, sender_name) Inbound Email notification job Convenience wrapper that builds a NotificationMessage with email details
GET /users/notification-preferences Outbound Frontend settings Returns signals_enabled, preferred_channels, channel_by_type
PATCH /users/integrations/{integration_id}/notifications Inbound Frontend settings Toggles notifications_enabled for a specific notification channel (via enabled query param)
TelegramChannel.send(msg) Outbound Telegram Bot API POST /bot{token}/sendMessage with chat_id and parse_mode=Markdown
ThreemaChannel.send(msg) Outbound Threema Gateway HTTPS POST with from, to, text fields

Known Trade-offs and Debt

Item Impact Remediation
No delivery receipts The system logs API success/failure but has no confirmation that the user read the notification Integrate delivery status callbacks where available (Telegram read receipts are not available via Bot API)
No per-notification-type preferences Users cannot selectively disable specific notification types (e.g., keep email alerts, disable meeting prep) Add a notification_typeenabled mapping in user preferences
Threema rate limiting is reactive HTTP 429 from Threema Gateway is caught and logged but the message is not retried Add the 429 response to the retry policy with appropriate backoff
Channel discovery at service init Available channel types are determined at SignalsService.__init__() based on environment variables. If Telegram bot token is not configured, the channel is not created Consider lazy channel initialization or a registry pattern