Skip to content
Back to skills

613 Notification Flow 5dc95fbc

ASecurity

This document traces the complete flow of notifications from research completion/failure through to delivery via Apprise.

  • 9 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 11, 2026
securitypythongobashsqlflasktestinggitapidatabasesecurity

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 11, 2026

npx -y skills add tools-only/X-Skills --skill 613-notification_flow_5dc95fbc --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of 613 Notification Flow 5dc95fbc?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for 613 Notification Flow 5dc95fbc
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/tools-only-613-notification-flow-5dc95fbc/badge)](https://www.skillsdirectory.com/skills/tools-only-613-notification-flow-5dc95fbc)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

SKILL.md
# Notification Flow - Complete Trace

This document traces the complete flow of notifications from research completion/failure through to delivery via Apprise.

## Overview

Notifications are sent when:
1. **Research completes successfully** → `RESEARCH_COMPLETED` event
2. **Research fails** → `RESEARCH_FAILED` event
3. **Research is queued** → `RESEARCH_QUEUED` event
4. **Subscription updates** → `SUBSCRIPTION_UPDATE` event
5. **Subscription errors** → `SUBSCRIPTION_ERROR` event
6. **API quota/rate limits exceeded** → `API_QUOTA_WARNING` event
7. **Authentication fails** → `AUTH_ISSUE` event

## Complete Flow: Research Completed

### 1. Research Thread Completes (`research_service.py:1166-1168`)

When research finishes successfully in the background thread:

```python
# src/local_deep_research/web/services/research_service.py:1166
cleanup_research_resources(
    research_id, active_research, termination_flags, username
)
```

### 2. Cleanup Calls Queue Processor (`research_service.py:1727`)

The cleanup function notifies the queue processor:

```python
# src/local_deep_research/web/services/research_service.py:1727
queue_processor.notify_research_completed(username, research_id, user_password=user_password)
```

**Key Points:**
- Called from background research thread
- Passes `user_password` for secure database access
- Uses `processor_v2` which handles encrypted per-user databases

### 3. Queue Processor Updates Status (`processor_v2.py:278-306`)

```python
# src/local_deep_research/web/queue/processor_v2.py:278-306
def notify_research_completed(self, username: str, research_id: str, user_password: str = None):
    with get_user_db_session(username, user_password) as session:
        # Update queue status
        queue_service = UserQueueService(session)
        queue_service.update_task_status(research_id, "completed")

        # Send notification if enabled
        self._send_research_notification(
            session=session,
            username=username,
            research_id=research_id,
            event_type="RESEARCH_COMPLETED",
        )
```

**Key Points:**
- Opens encrypted user database with password
- Updates queue status first
- Delegates to `_send_research_notification` helper

### 4. Build Notification Context (`processor_v2.py:357-243`)

The helper method prepares the notification:

```python
# src/local_deep_research/web/queue/processor_v2.py:357-243
def _send_research_notification(
    self,
    session,
    username: str,
    research_id: str,
    event_type: str,
    error_message: str = None,
):
    # Get settings snapshot for thread-safe notification sending
    settings_manager = SettingsManager(session)
    settings_snapshot = settings_manager.get_settings_snapshot()

    # Lookup research details (with retry logic for timing issues)
    research = session.query(ResearchHistory).filter_by(id=research_id).first()

    if research:
        # Create notification manager with settings snapshot and user_id
        notification_manager = NotificationManager(
            settings_snapshot=settings_snapshot,
            user_id=username  # Enables per-user rate limiting
        )

        # Build full URL for notification
        full_url = build_notification_url(
            f"/research/{research_id}",
            settings_manager=settings_manager,
        )

        # Build notification context
        context = {
            "query": research.query or "Unknown query",
            "research_id": research_id,
            "summary": report_content[:200] + "...",  # Truncated
            "url": full_url,  # Full clickable URL
        }

        # Send notification (user_id already set in manager init)
        result = notification_manager.send_notification(
            event_type=EventType.RESEARCH_COMPLETED,
            context=context,
        )
```

**Key Points:**
- **Settings Snapshot**: Captures settings at notification time (thread-safe)
- **Research Lookup**: Queries database for research details with 3 retry attempts
- **URL Building**: Constructs full URL using `app.external_url` or `app.host`/`app.port`
- **Context Building**: Includes query, research_id, summary (truncated to 200 chars), full URL
- **No Session Passed**: NotificationManager gets `settings_snapshot`, NOT session (thread-safe)

### 5. NotificationManager Checks Settings (`manager.py:91-126`)

```python
# src/local_deep_research/notifications/manager.py:91-126
def send_notification(
    self,
    event_type: EventType,
    context: Dict[str, Any],
    user_id: Optional[str] = None,
    force: bool = False,
) -> bool:
    # Check if notifications are enabled for this event type
    should_notify = self._should_notify(event_type, user_id)
    if not force and not should_notify:
        logger.debug(f"Notifications disabled for event type: {event_type.value}")
        return False

    # Check rate limit
    rate_limit_ok = self._rate_limiter.allow(user_id or "default")
    if not force and not rate_limit_ok:
        raise RateLimitError("Notification rate limit exceeded")

    # Get service URLs from settings snapshot
    service_urls = self._get_setting("notifications.service_url", default="")

    if not service_urls or not service_urls.strip():
        logger.debug("No notification service URLs configured")
        return False

    # Send notification with service URLs
    result = self.service.send_event(event_type, context, service_urls=service_urls)

    return result
```

**Settings Checked (from `settings_snapshot`):**
1. **`notifications.on_research_completed`** - Is this event type enabled? (default: False for most events)
2. **Per-User Rate Limits** - Check shared rate limiter with user-specific limits:
   - `notifications.rate_limit_per_hour` (default: 10) - Configured per user
   - `notifications.rate_limit_per_day` (default: 50) - Configured per user
   - Each user has independent rate limit counters
3. **`notifications.service_url`** - Comma-separated list of Apprise URLs (required)

**Key Points:**
- All settings come from `settings_snapshot` (captured earlier)
- Rate limiter is **shared singleton** with per-user limits and counters
- Each user's rate limits are configured independently when `NotificationManager` is created with `user_id`
- One user hitting their rate limit does NOT affect other users
- `force=False` by default (respects settings and rate limits)

### 6. NotificationService Formats Message (`service.py:198-235`)

```python
# src/local_deep_research/notifications/service.py:198-235
def send_event(
    self,
    event_type: EventType,
    context: Dict[str, Any],
    service_urls: Optional[str] = None,
    tag: Optional[str] = None,
    custom_template: Optional[Dict[str, str]] = None,
) -> bool:
    # Format notification using template
    message = NotificationTemplate.format(
        event_type, context, custom_template
    )

    # Send notification
    result = self.send(
        title=message["title"],
        body=message["body"],
        service_urls=service_urls,
        tag=tag,
    )
    return result
```

**Template Used (`templates.py`):**
```python
EventType.RESEARCH_COMPLETED: {
    "title": "Research Completed: {query}",
    "body": "Your research '{query}' has completed successfully.\n\n"
            "Summary: {summary}\n\n"
            "View results: {url}",
}
```

**Context Variables:**
- `{query}` - Research query text
- `{summary}` - Truncated report content (max 200 chars)
- `{url}` - Full clickable URL to view research
- `{research_id}` - Research ID (available but not used in default template)

### 7. NotificationService Sends via Apprise (`service.py:47-196`)

```python
# src/local_deep_research/notifications/service.py:47-196
def send(
    self,
    title: str,
    body: str,
    service_urls: Optional[str] = None,
    tag: Optional[str] = None,
    attach: Optional[List[str]] = None,
) -> bool:
    # Retry logic with exponential backoff
    retry_delay = INITIAL_RETRY_DELAY  # 0.5s

    for attempt in range(1, MAX_RETRY_ATTEMPTS + 1):  # 3 attempts
        try:
            # Create temporary Apprise instance
            # Automatically garbage collected by Python when out of scope
            apprise_instance = apprise.Apprise()
            apprise_instance.add(service_urls, tag=tag)

            # Send notification
            notify_result = apprise_instance.notify(
                title=title,
                body=body,
                tag=tag,
                attach=attach,
            )

            if notify_result:
                return True

            # Retry with exponential backoff
            time.sleep(retry_delay)
            retry_delay *= RETRY_BACKOFF_MULTIPLIER  # 2x

        except Exception as e:
            logger.error(f"Error sending notification: {e}")
            time.sleep(retry_delay)
            retry_delay *= RETRY_BACKOFF_MULTIPLIER

    # All attempts failed
    raise SendError("Failed to send notification after 3 attempts")
```

**Retry Strategy:**
- **Attempt 1**: Immediate send
- **Attempt 2**: Wait 0.5s, retry
- **Attempt 3**: Wait 1.0s, retry
- **After 3 attempts**: Raise `SendError`

**Apprise Instances:**
- Temporary instances created for each send operation
- Automatically garbage collected by Python
- Multiple service URLs supported (comma-separated)

### 8. Apprise Delivers Notification

Apprise handles the actual delivery to configured services:

```python
# User's settings: notifications.service_url
"discord://webhook_id/webhook_token,mailto://user:password@smtp.gmail.com"
```

**Supported Services** (via Apprise):
- Discord, Slack, Telegram
- Email (SMTP, Gmail, etc.)
- Pushover, Gotify
- Many more: https://github.com/caronc/apprise/wiki

**Security:**
- Service URLs encrypted at rest (AES-256 via SQLCipher)
- Encryption key derived from user's login password (PBKDF2-SHA512)
- URLs masked in logs (e.g., `discord://webhook_id/***`)

## Complete Flow: Research Failed

Similar to completed flow, but:

1. **Entry Point**: `research_service.py:1642` (exception handler)
2. **Queue Method**: `queue_processor.queue_error_update()` (processor.py)
3. **Notification**: Sent from `processor.py:577-606` (error update handler)
4. **Event Type**: `EventType.RESEARCH_FAILED`
5. **Context**: Includes `error` field instead of `summary`

**Template for RESEARCH_FAILED:**
```python
EventType.RESEARCH_FAILED: {
    "title": "Research Failed: {query}",
    "body": "Research on '{query}' failed.\n\n"
            "Error: {error}\n\n"
            "Please check the logs for more details.",
}
```

**Note**: Error messages are sanitized for security to avoid exposing sensitive information in notifications.

## Settings Snapshot Pattern

**Why Settings Snapshot?**

Notifications are sent from background threads that shouldn't access Flask `g` or SQLite sessions (not thread-safe). The solution is to **capture settings once** in the main thread and pass them as a dict.

**How It Works:**

```python
# 1. In main thread (with database session)
settings_manager = SettingsManager(session)
settings_snapshot = settings_manager.get_settings_snapshot()
# Returns dict like: {"notifications.service_url": "...", "notifications.on_research_completed": True, ...}

# 2. Pass to NotificationManager (thread-safe - no session!)
notification_manager = NotificationManager(
    settings_snapshot=settings_snapshot,
    user_id=username
)

# 3. NotificationManager reads from snapshot
def _get_setting(self, key: str, default: Any = None) -> Any:
    return self._settings_snapshot.get(key, default)
```

**Benefits:**
- ✅ Thread-safe (no database access in background threads)
- ✅ Consistent settings (captured at notification time, not changed mid-notification)
- ✅ No Flask `g` context needed
- ✅ Works from queue processors, schedulers, etc.

## Rate Limiting

**Implementation:** In-memory, per-user limits and counters, shared singleton

```python
class RateLimiter:
    _lock = threading.Lock()
    _user_limits: Dict[str, tuple[int, int]] = {}  # user_id -> (max_per_hour, max_per_day)
    _hourly_counts: Dict[str, deque] = {}  # user_id -> timestamps
    _daily_counts: Dict[str, deque] = {}   # user_id -> timestamps

    def set_user_limits(self, user_id: str, max_per_hour: int, max_per_day: int):
        """Configure rate limits for a specific user."""
        with self._lock:
            self._user_limits[user_id] = (max_per_hour, max_per_day)

    def allow(self, user_id: str) -> bool:
        with self._lock:  # Thread-safe
            now = datetime.now(timezone.utc)

            # Clean old entries (> 1 hour, > 1 day)
            self._clean_old_entries(user_id, now)

            # Get user-specific limits or defaults
            max_per_hour, max_per_day = self._user_limits.get(
                user_id, (self.max_per_hour, self.max_per_day)
            )

            # Check limits using user-specific values
            if len(self._hourly_counts[user_id]) >= max_per_hour:
                return False
            if len(self._daily_counts[user_id]) >= max_per_day:
                return False

            # Record this notification
            self._hourly_counts[user_id].append(now)
            self._daily_counts[user_id].append(now)

            return True
```

**Shared Singleton Pattern with Per-User Configuration:**
```python
class NotificationManager:
    _shared_rate_limiter: Optional["RateLimiter"] = None
    _rate_limiter_lock = threading.Lock()

    def __init__(self, settings_snapshot: Dict[str, Any], user_id: str):
        with NotificationManager._rate_limiter_lock:
            if NotificationManager._shared_rate_limiter is None:
                # Create shared rate limiter with defaults
                NotificationManager._shared_rate_limiter = RateLimiter(
                    max_per_hour=settings_snapshot.get("notifications.rate_limit_per_hour", 10),
                    max_per_day=settings_snapshot.get("notifications.rate_limit_per_day", 50),
                )

            self._rate_limiter = NotificationManager._shared_rate_limiter

            # Configure per-user limits (user_id is required)
            max_per_hour = settings_snapshot.get("notifications.rate_limit_per_hour", 10)
            max_per_day = settings_snapshot.get("notifications.rate_limit_per_day", 50)
            self._rate_limiter.set_user_limits(user_id, max_per_hour, max_per_day)
```

**Key Points:**
- **One rate limiter instance** across all NotificationManager instances (singleton)
- **Per-user rate limits**: Each user can have different limits based on their settings
- **Per-user counters**: Each user has independent notification counters
- **User isolation**: One user hitting their limit does NOT affect others
- **Thread-safe** with `threading.Lock()` for all operations
- **Automatic cleanup** of old entries (> 1 hour, > 1 day)
- **Periodic cleanup** of inactive users (every 24 hours)
- **Memory efficient**: ~24 bytes per user for limit storage

## Configuration

### Required Settings

```python
# Service URL (required) - comma-separated list
notifications.service_url = "discord://webhook_id/token,mailto://user:pass@smtp.gmail.com"

# Event-specific toggles (default: False for most events)
notifications.on_research_completed = True   # Default: True
notifications.on_research_failed = True      # Default: True
notifications.on_research_queued = False     # Default: False
notifications.on_subscription_update = True  # Default: True
notifications.on_subscription_error = False  # Default: False
notifications.on_api_quota_warning = False   # Default: False
notifications.on_auth_issue = False          # Default: False

# Rate limits (per-user, configured independently for each user)
notifications.rate_limit_per_hour = 10   # Max notifications per hour (per user)
notifications.rate_limit_per_day = 50    # Max notifications per day (per user)

# URL configuration (for clickable links)
app.external_url = "https://ldr.example.com"  # Preferred
# OR
app.host = "localhost"
app.port = 5000
```

### Testing Notifications

```python
from local_deep_research.notifications.manager import NotificationManager

# Create manager with settings and required user_id
notification_manager = NotificationManager(
    settings_snapshot={},
    user_id="test_user"  # Required for per-user rate limiting
)

# Test a service URL
result = notification_manager.test_service("discord://webhook_id/webhook_token")
print(result)  # {'success': True, 'message': 'Test notification sent successfully'}
```

## Error Handling

### Rate Limit Exceeded

```python
try:
    notification_manager.send_notification(
        event_type=EventType.RESEARCH_COMPLETED,
        context=context,
    )
except RateLimitError as e:
    logger.warning(f"Rate limit exceeded: {e}")
    # Notification not sent, user needs to wait
    # Note: This only affects the user that the manager was created for
```

### Send Failure (After 3 Retries)

```python
try:
    result = service.send(title="...", body="...", service_urls="...")
except SendError as e:
    logger.error(f"Failed to send notification after 3 attempts: {e}")
    # All retry attempts exhausted
```

### No Service URLs Configured

```python
result = notification_manager.send_notification(...)
# Returns: False (no error raised)
# Log: "No notification service URLs configured for user {user_id}"
```

### Notifications Disabled for Event Type

```python
# settings_snapshot = {"notifications.on_research_completed": False}
result = notification_manager.send_notification(
    event_type=EventType.RESEARCH_COMPLETED,
    context=context
)
# Returns: False (respects user preference)
# Log: "Notifications disabled for event type: research_completed"
```

## Architecture Diagram

```
[Research Thread]
      ↓
cleanup_research_resources()
      ↓
queue_processor.notify_research_completed(username, research_id, password)
      ↓
[Queue Processor - Main Thread]
      ↓
get_user_db_session(username, password) → [Encrypted DB]
      ↓
SettingsManager(session).get_settings_snapshot() → settings_snapshot
      ↓
ResearchHistory.query.filter_by(id=research_id).first() → research details
      ↓
build_notification_url() → full_url
      ↓
NotificationManager(settings_snapshot=settings_snapshot, user_id=username)
      ↓
notification_manager.send_notification(
    event_type=EventType.RESEARCH_COMPLETED,
    context={query, research_id, summary, url}
)
      ↓
[NotificationManager]
      ├─ Check: notifications.on_research_completed
      ├─ Check: Per-user rate limiter (hourly/daily)
      └─ Get: notifications.service_url
      ↓
NotificationService.send_event(event_type, context, service_urls)
      ↓
NotificationTemplate.format(event_type, context) → {title, body}
      ↓
NotificationService.send(title, body, service_urls)
      ↓
[Retry Loop: 3 attempts with exponential backoff]
      ├─ Attempt 1: Immediate
      ├─ Attempt 2: Wait 0.5s
      └─ Attempt 3: Wait 1.0s
      ↓
Apprise.add(service_urls)
      ↓
Apprise.notify(title=title, body=body)
      ↓
[Apprise - Delivery]
      ├─ Discord webhook
      ├─ SMTP email
      └─ Other services...
```

## Key Design Decisions

1. **Settings Snapshot Pattern**: Avoids thread-safety issues with database sessions
2. **Shared Rate Limiter with Per-User Limits**: Single rate limiter instance ensures correct per-user enforcement while maintaining separate limits and counters for each user
3. **User Isolation**: Each user's rate limits are independent - one user hitting their limit does not affect others
4. **Temporary Apprise Instances**: Created per-send and automatically garbage collected
5. **Exponential Backoff**: 3 retry attempts with increasing delays (0.5s → 1.0s → 2.0s)
6. **Encrypted Storage**: Service URLs stored encrypted in per-user SQLCipher database
7. **URL Masking**: Credentials hidden in logs (e.g., `discord://webhook_id/***`)
8. **No Session in Manager**: NotificationManager never receives database session (thread-safe)
9. **Error Sanitization**: Error messages sanitized in notifications to prevent information exposure

## Additional Notification Events

### Research Queued Notifications

Sent when research is added to the queue:

- **Event**: `EventType.RESEARCH_QUEUED`
- **Triggered from**: `web/queue/manager.py` when adding research to queue
- **Context**: `query`, `position`, `wait_time`
- **Default**: Disabled (opt-in)

### API Quota Warnings

Sent when API rate limits are exceeded:

- **Event**: `EventType.API_QUOTA_WARNING`
- **Triggered from**: `error_handling/error_reporter.py` when detecting rate limit errors
- **Context**: `service`, `current`, `limit`, `reset_time`
- **Default**: Disabled (opt-in)

### Authentication Issues

Sent when API authentication fails:

- **Event**: `EventType.AUTH_ISSUE`
- **Triggered from**: `error_handling/error_reporter.py` when detecting auth errors
- **Context**: `service`
- **Default**: Disabled (opt-in)

### Subscription Notifications

Sent when subscriptions update or fail:

- **Events**: `EventType.SUBSCRIPTION_UPDATE`, `EventType.SUBSCRIPTION_ERROR`
- **Context**: `subscription_name`, `subscription_id`, `item_count`/`error`, `url`

## Testing

Run notification tests:
```bash
pdm run python -m pytest tests/notifications/ -v
```

All 98 tests passing ✓ (including 7 new per-user rate limiting tests)

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.