Browse Source

Added template system to notification module.

maziggy 5 months ago
parent
commit
29aadfb774

+ 208 - 147
PLAN.md

@@ -1,186 +1,247 @@
-# Notifications Feature Implementation Plan
+# Notification Templates Management System
 
 ## Overview
-Add push notifications for print events (start, complete, fail) with support for multiple notification providers.
 
-## Supported Providers (Initial Release)
-1. **CallMeBot/WhatsApp** - Free, uses HTTP API with phone number + API key
-2. **ntfy** - Self-hosted or ntfy.sh, simple HTTP POST
-3. **Pushover** - Commercial ($5 one-time), HTTP API with user key + app token
-4. **Telegram** - Free bot API, requires bot token + chat ID
-5. **Email (SMTP)** - Universal fallback
+Replace hardcoded notification messages with a flexible template system that allows users to customize notification content per event type, with provider-specific formatting support.
 
-## Database Design
+---
+
+## Data Model
+
+### New Table: `notification_templates`
 
-### New Table: `notification_providers`
 ```sql
-CREATE TABLE notification_providers (
+CREATE TABLE notification_templates (
     id INTEGER PRIMARY KEY,
-    name TEXT NOT NULL,                    -- User-defined name ("My WhatsApp")
-    provider_type TEXT NOT NULL,           -- "callmebot", "ntfy", "pushover", "telegram", "email"
-    enabled BOOLEAN DEFAULT true,
+    event_type VARCHAR(50) NOT NULL,  -- print_start, print_complete, etc.
+    name VARCHAR(100) NOT NULL,       -- User-friendly name
+    title_template TEXT NOT NULL,     -- Template for notification title
+    body_template TEXT NOT NULL,      -- Template for notification body
+    is_default BOOLEAN DEFAULT 0,     -- System default (non-deletable)
+    created_at DATETIME,
+    updated_at DATETIME
+);
+```
 
-    -- Provider-specific config (JSON or individual fields)
-    config TEXT NOT NULL,                  -- JSON: {"phone": "+1234", "apikey": "xxx"}
+**Event Types:**
+- `print_start`
+- `print_complete`
+- `print_failed`
+- `print_stopped`
+- `print_progress`
+- `printer_offline`
+- `printer_error`
+- `filament_low`
+- `maintenance_due`
+- `test` (for test notifications)
 
-    -- Event triggers (which events send notifications)
-    on_print_start BOOLEAN DEFAULT false,
-    on_print_complete BOOLEAN DEFAULT true,
-    on_print_failed BOOLEAN DEFAULT true,
+---
 
-    -- Optional: Link to specific printer (NULL = all printers)
-    printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
+## Template Variables
 
-    -- Timestamps
-    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
-);
-```
+Variables use `{variable_name}` syntax (Python format strings).
 
-### Config JSON Structure by Provider
-```python
-# CallMeBot/WhatsApp
-{"phone": "+1234567890", "apikey": "123456"}
+### Per-Event Variables:
 
-# ntfy
-{"server": "https://ntfy.sh", "topic": "my-printer", "auth_token": "optional"}
+| Event | Variables |
+|-------|-----------|
+| `print_start` | `{printer}`, `{filename}`, `{estimated_time}` |
+| `print_complete` | `{printer}`, `{filename}`, `{duration}`, `{filament_grams}` |
+| `print_failed` | `{printer}`, `{filename}`, `{duration}`, `{reason}` |
+| `print_stopped` | `{printer}`, `{filename}`, `{duration}` |
+| `print_progress` | `{printer}`, `{filename}`, `{progress}`, `{remaining_time}` |
+| `printer_offline` | `{printer}` |
+| `printer_error` | `{printer}`, `{error_type}`, `{error_detail}` |
+| `filament_low` | `{printer}`, `{slot}`, `{remaining_percent}`, `{color}` |
+| `maintenance_due` | `{printer}`, `{items}` (formatted list) |
+| `test` | `{app_name}` |
 
-# Pushover
-{"user_key": "xxx", "app_token": "yyy", "priority": 0}
+### Common Variables (all events):
+- `{timestamp}` - Current date/time
+- `{app_name}` - "BambuTrack"
 
-# Telegram
-{"bot_token": "123:ABC", "chat_id": "12345678"}
+---
 
-# Email (SMTP)
-{"smtp_server": "smtp.gmail.com", "smtp_port": 587, "username": "x", "password": "y", "from_email": "x@gmail.com", "to_email": "dest@example.com"}
-```
+## Default Templates
 
-## Backend Implementation
-
-### 1. Model: `backend/app/models/notification.py`
-- SQLAlchemy model for `notification_providers` table
-- Relationship to Printer (optional, nullable)
-
-### 2. Schema: `backend/app/schemas/notification.py`
-- `NotificationProviderBase` - Common fields
-- `NotificationProviderCreate` - For creating new providers
-- `NotificationProviderUpdate` - For partial updates
-- `NotificationProviderResponse` - API response with id/timestamps
-- `NotificationTestRequest` - For testing notifications
-
-### 3. Service: `backend/app/services/notification_service.py`
-Core notification dispatcher with provider implementations:
-
-```python
-class NotificationService:
-    async def send_notification(self, provider: NotificationProvider, event: str, data: dict) -> bool
-    async def on_print_start(self, printer_id: int, data: dict, db: AsyncSession)
-    async def on_print_complete(self, printer_id: int, status: str, data: dict, db: AsyncSession)
-
-    # Provider-specific methods
-    async def _send_callmebot(self, config: dict, message: str) -> bool
-    async def _send_ntfy(self, config: dict, title: str, message: str) -> bool
-    async def _send_pushover(self, config: dict, title: str, message: str) -> bool
-    async def _send_telegram(self, config: dict, message: str) -> bool
-    async def _send_email(self, config: dict, subject: str, body: str) -> bool
-```
+Pre-seeded templates for each event (marked `is_default=True`):
 
-### 4. Routes: `backend/app/api/routes/notifications.py`
-```
-GET    /notifications/              - List all providers
-POST   /notifications/              - Create provider
-GET    /notifications/{id}          - Get provider details
-PATCH  /notifications/{id}          - Update provider
-DELETE /notifications/{id}          - Delete provider
-POST   /notifications/{id}/test     - Send test notification
-POST   /notifications/test-config   - Test config before saving
 ```
+print_start:
+  title: "Print Started"
+  body: "{printer}: {filename}\nEstimated: {estimated_time}"
 
-### 5. Integration in `main.py`
-Add calls to notification service in existing event handlers:
-- `on_print_start()` - After smart_plug_manager call (line ~244)
-- `on_print_complete()` - After archive update (line ~580)
+print_complete:
+  title: "Print Completed"
+  body: "{printer}: {filename}\nTime: {duration}\nFilament: {filament_grams}g"
 
-## Frontend Implementation
+print_failed:
+  title: "Print Failed"
+  body: "{printer}: {filename}\nTime: {duration}\nReason: {reason}"
 
-### 1. API Client: `frontend/src/api/client.ts`
-Add types and API methods for notification providers.
+print_stopped:
+  title: "Print Stopped"
+  body: "{printer}: {filename}\nTime: {duration}"
 
-### 2. Components
-- `NotificationProviderCard.tsx` - Display single provider with enable/disable toggle
-- `AddNotificationModal.tsx` - Modal for adding/editing providers with provider-specific forms
+print_progress:
+  title: "Print {progress}% Complete"
+  body: "{printer}: {filename}\nRemaining: {remaining_time}"
 
-### 3. Settings Page Integration
-Add "Notifications" section in SettingsPage.tsx (similar to Smart Plugs section):
-- List of configured providers
-- Add button
-- Per-provider enable/disable
-- Test button
-- Event toggles (start/complete/failed)
+printer_offline:
+  title: "Printer Offline"
+  body: "{printer} has disconnected"
 
-## Message Templates
+printer_error:
+  title: "Printer Error: {error_type}"
+  body: "{printer}\n{error_detail}"
 
-### Print Started
-```
-🖨️ Print Started
-{printer_name}: {filename}
-Estimated time: {est_time}
-```
+filament_low:
+  title: "Filament Low"
+  body: "{printer}: Slot {slot} at {remaining_percent}%"
 
-### Print Completed
-```
-✅ Print Completed
-{printer_name}: {filename}
-Time: {actual_time}
-Filament: {filament_used}g
-```
+maintenance_due:
+  title: "Maintenance Due"
+  body: "{printer}:\n{items}"
 
-### Print Failed
-```
-❌ Print Failed
-{printer_name}: {filename}
-Status: {failure_reason}
-Progress: {progress}%
+test:
+  title: "BambuTrack Test"
+  body: "This is a test notification. If you see this, notifications are working!"
 ```
 
-## Implementation Order
+---
+
+## Provider-Specific Formatting
+
+The template system supports provider-specific formatting via a simple approach:
+
+1. **Plain text** (default) - Used for CallMeBot, ntfy, Pushover, Email
+2. **Markdown** - Automatically applied for Telegram (wrap title in `*bold*`)
+
+The notification service will:
+- Render the template with variables
+- Apply provider-specific formatting when sending
+
+---
 
-### Phase 1: Backend Core
-1. Create notification model with migrations
-2. Create notification schema
-3. Create notification service with all 5 providers
-4. Create notification routes (CRUD + test)
-5. Register routes in main.py
-6. Integrate into print event handlers
+## Implementation Steps
 
-### Phase 2: Frontend
-7. Add API types and methods
-8. Create NotificationProviderCard component
-9. Create AddNotificationModal component
-10. Add Notifications section to SettingsPage
+### Backend
 
-### Phase 3: Testing & Polish
-11. Test each provider
-12. Add error handling and logging
-13. Handle network failures gracefully (don't block print events)
+1. **Create model** `backend/app/models/notification_template.py`
+   - NotificationTemplate SQLAlchemy model
+
+2. **Create schemas** `backend/app/schemas/notification_template.py`
+   - NotificationTemplateCreate, Update, Response
+   - TemplateVariables (documentation of available vars per event)
+
+3. **Add migration** in `backend/app/core/database.py`
+   - Create table if not exists
+   - Seed default templates
+
+4. **Create API routes** `backend/app/api/routes/notification_templates.py`
+   - `GET /api/v1/notification-templates` - List all templates
+   - `GET /api/v1/notification-templates/{id}` - Get single template
+   - `PUT /api/v1/notification-templates/{id}` - Update template
+   - `POST /api/v1/notification-templates/{id}/reset` - Reset to default
+   - `GET /api/v1/notification-templates/variables` - List available variables per event
+   - `POST /api/v1/notification-templates/preview` - Preview template with sample data
+
+5. **Update notification service** `backend/app/services/notification_service.py`
+   - Load templates from database
+   - Render templates with variables
+   - Remove hardcoded message builders
+
+6. **Register routes** in `backend/app/main.py`
+
+### Frontend
+
+7. **Add API client methods** `frontend/src/api/client.ts`
+   - getNotificationTemplates, updateNotificationTemplate, etc.
+
+8. **Create template editor component** `frontend/src/components/NotificationTemplateEditor.tsx`
+   - Template editing UI with variable insertion buttons
+   - Live preview with sample data
+   - Reset to default button
+
+9. **Update SettingsPage** `frontend/src/pages/SettingsPage.tsx`
+   - Add "Templates" sub-section in Notifications tab
+   - List all templates with edit capability
+
+---
+
+## UI Design
+
+### Templates Section (in Settings > Notifications)
+
+```
++--------------------------------------------------+
+| Message Templates                                |
+| Customize notification messages for each event   |
++--------------------------------------------------+
+|                                                  |
+| +----------------+  +----------------+           |
+| | Print Started  |  | Print Complete |  ...     |
+| | "Print Started"|  | "Print Compl..." |        |
+| | [Edit]         |  | [Edit]         |          |
+| +----------------+  +----------------+           |
+|                                                  |
++--------------------------------------------------+
+```
+
+### Template Editor Modal
+
+```
++--------------------------------------------------+
+| Edit Template: Print Complete              [X]   |
++--------------------------------------------------+
+| Title:                                           |
+| [Print Completed_________________________]       |
+|                                                  |
+| Body:                                            |
+| +----------------------------------------------+ |
+| | {printer}: {filename}                        | |
+| | Time: {duration}                             | |
+| | Filament: {filament_grams}g                  | |
+| +----------------------------------------------+ |
+|                                                  |
+| Available Variables:                             |
+| [+printer] [+filename] [+duration] [+filament]   |
+|                                                  |
+| Preview:                                         |
+| +----------------------------------------------+ |
+| | Title: Print Completed                       | |
+| | Body:  Bambu X1C: Benchy.3mf                 | |
+| |        Time: 1h 23m                          | |
+| |        Filament: 15.2g                       | |
+| +----------------------------------------------+ |
+|                                                  |
+| [Reset to Default]              [Cancel] [Save]  |
++--------------------------------------------------+
+```
 
-## Technical Notes
+---
 
-### Async HTTP Requests
-Use `httpx` (already available) for async HTTP calls to notification APIs.
+## File Changes Summary
 
-### Error Handling
-- Notifications should NEVER block print events
-- Log failures but continue processing
-- Store last_error and last_success timestamps for UI feedback
+| File | Action |
+|------|--------|
+| `backend/app/models/notification_template.py` | Create |
+| `backend/app/schemas/notification_template.py` | Create |
+| `backend/app/api/routes/notification_templates.py` | Create |
+| `backend/app/core/database.py` | Modify (add migration + seeding) |
+| `backend/app/models/__init__.py` | Modify (export new model) |
+| `backend/app/services/notification_service.py` | Modify (use templates) |
+| `backend/app/main.py` | Modify (register routes) |
+| `frontend/src/api/client.ts` | Modify (add API methods + types) |
+| `frontend/src/components/NotificationTemplateEditor.tsx` | Create |
+| `frontend/src/pages/SettingsPage.tsx` | Modify (add templates section) |
 
-### Security
-- Store credentials in database (SQLite file already contains access codes)
-- Consider encryption for sensitive fields in future
+---
 
-### Rate Limiting
-- Debounce rapid events (don't spam on quick start/stop cycles)
-- Consider per-provider rate limits
+## Notes
 
-## Questions for User
-None - proceeding with the 5 providers as discussed.
+- Default templates cannot be deleted, only modified and reset
+- Templates are language-agnostic (user writes in their preferred language)
+- The existing `notification_language` setting can be removed later (templates replace i18n)
+- Variables that are unavailable for an event will render as empty string
+- Template rendering uses safe formatting (missing vars don't crash)

+ 148 - 0
backend/app/api/routes/notification_templates.py

@@ -0,0 +1,148 @@
+"""API routes for notification template management."""
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import get_db
+from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
+from backend.app.schemas.notification_template import (
+    NotificationTemplateResponse,
+    NotificationTemplateUpdate,
+    EventVariablesResponse,
+    TemplatePreviewRequest,
+    TemplatePreviewResponse,
+    EVENT_VARIABLES,
+    SAMPLE_DATA,
+)
+from backend.app.services.notification_service import notification_service
+
+router = APIRouter(prefix="/notification-templates", tags=["notification-templates"])
+
+
+# Event type display names
+EVENT_NAMES = {
+    "print_start": "Print Started",
+    "print_complete": "Print Completed",
+    "print_failed": "Print Failed",
+    "print_stopped": "Print Stopped",
+    "print_progress": "Print Progress",
+    "printer_offline": "Printer Offline",
+    "printer_error": "Printer Error",
+    "filament_low": "Filament Low",
+    "maintenance_due": "Maintenance Due",
+    "test": "Test Notification",
+}
+
+
+@router.get("", response_model=list[NotificationTemplateResponse])
+async def get_templates(db: AsyncSession = Depends(get_db)):
+    """Get all notification templates."""
+    result = await db.execute(
+        select(NotificationTemplate).order_by(NotificationTemplate.id)
+    )
+    return result.scalars().all()
+
+
+@router.get("/variables", response_model=list[EventVariablesResponse])
+async def get_variables():
+    """Get available variables for each event type."""
+    return [
+        EventVariablesResponse(
+            event_type=event_type,
+            event_name=EVENT_NAMES.get(event_type, event_type),
+            variables=variables,
+        )
+        for event_type, variables in EVENT_VARIABLES.items()
+    ]
+
+
+@router.get("/{template_id}", response_model=NotificationTemplateResponse)
+async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
+    """Get a single notification template."""
+    result = await db.execute(
+        select(NotificationTemplate).where(NotificationTemplate.id == template_id)
+    )
+    template = result.scalar_one_or_none()
+    if not template:
+        raise HTTPException(status_code=404, detail="Template not found")
+    return template
+
+
+@router.put("/{template_id}", response_model=NotificationTemplateResponse)
+async def update_template(
+    template_id: int,
+    update: NotificationTemplateUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a notification template."""
+    result = await db.execute(
+        select(NotificationTemplate).where(NotificationTemplate.id == template_id)
+    )
+    template = result.scalar_one_or_none()
+    if not template:
+        raise HTTPException(status_code=404, detail="Template not found")
+
+    if update.title_template is not None:
+        template.title_template = update.title_template
+    if update.body_template is not None:
+        template.body_template = update.body_template
+
+    await db.commit()
+    await db.refresh(template)
+
+    # Clear template cache so changes take effect immediately
+    notification_service.clear_template_cache()
+
+    return template
+
+
+@router.post("/{template_id}/reset", response_model=NotificationTemplateResponse)
+async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
+    """Reset a notification template to its default values."""
+    result = await db.execute(
+        select(NotificationTemplate).where(NotificationTemplate.id == template_id)
+    )
+    template = result.scalar_one_or_none()
+    if not template:
+        raise HTTPException(status_code=404, detail="Template not found")
+
+    # Find the default template
+    default = next(
+        (t for t in DEFAULT_TEMPLATES if t["event_type"] == template.event_type),
+        None,
+    )
+    if not default:
+        raise HTTPException(status_code=500, detail="Default template not found")
+
+    template.title_template = default["title_template"]
+    template.body_template = default["body_template"]
+
+    await db.commit()
+    await db.refresh(template)
+
+    # Clear template cache so changes take effect immediately
+    notification_service.clear_template_cache()
+
+    return template
+
+
+@router.post("/preview", response_model=TemplatePreviewResponse)
+async def preview_template(request: TemplatePreviewRequest):
+    """Preview a template with sample data."""
+    sample = SAMPLE_DATA.get(request.event_type, {})
+
+    # Safe template rendering - replace missing vars with empty string
+    def safe_format(template: str, data: dict) -> str:
+        result = template
+        for key, value in data.items():
+            result = result.replace("{" + key + "}", str(value))
+        # Remove any remaining unreplaced placeholders
+        import re
+        result = re.sub(r"\{[a-z_]+\}", "", result)
+        return result
+
+    return TemplatePreviewResponse(
+        title=safe_format(request.title_template, sample),
+        body=safe_format(request.body_template, sample),
+    )

+ 30 - 1
backend/app/core/database.py

@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
 
 async def init_db():
     # Import models to register them with SQLAlchemy
-    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note  # noqa: F401
+    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template  # noqa: F401
 
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)
@@ -42,6 +42,9 @@ async def init_db():
         # Run migrations for new columns (SQLite doesn't auto-add columns)
         await run_migrations(conn)
 
+    # Seed default notification templates
+    await seed_notification_templates()
+
 
 async def run_migrations(conn):
     """Add new columns to existing tables if they don't exist."""
@@ -127,3 +130,29 @@ async def run_migrations(conn):
     except Exception:
         # Column already exists
         pass
+
+
+async def seed_notification_templates():
+    """Seed default notification templates if they don't exist."""
+    from sqlalchemy import select
+    from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
+
+    async with async_session() as session:
+        # Check if templates already exist
+        result = await session.execute(select(NotificationTemplate).limit(1))
+        if result.scalar_one_or_none() is not None:
+            # Templates already seeded
+            return
+
+        # Insert default templates
+        for template_data in DEFAULT_TEMPLATES:
+            template = NotificationTemplate(
+                event_type=template_data["event_type"],
+                name=template_data["name"],
+                title_template=template_data["title_template"],
+                body_template=template_data["body_template"],
+                is_default=True,
+            )
+            session.add(template)
+
+        await session.commit()

+ 2 - 1
backend/app/main.py

@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
 from backend.app.core.database import init_db, async_session
 from sqlalchemy import select, or_
 from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman, updates, maintenance, camera
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
@@ -1018,6 +1018,7 @@ app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(notifications.router, prefix=app_settings.api_prefix)
+app.include_router(notification_templates.router, prefix=app_settings.api_prefix)
 app.include_router(spoolman.router, prefix=app_settings.api_prefix)
 app.include_router(updates.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.router, prefix=app_settings.api_prefix)

+ 2 - 0
backend/app/models/__init__.py

@@ -5,6 +5,7 @@ from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
 from backend.app.models.kprofile_note import KProfileNote
+from backend.app.models.notification_template import NotificationTemplate
 
 __all__ = [
     "Printer",
@@ -16,4 +17,5 @@ __all__ = [
     "PrinterMaintenance",
     "MaintenanceHistory",
     "KProfileNote",
+    "NotificationTemplate",
 ]

+ 90 - 0
backend/app/models/notification_template.py

@@ -0,0 +1,90 @@
+"""Notification template model for customizable notification messages."""
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class NotificationTemplate(Base):
+    """Model for notification message templates."""
+
+    __tablename__ = "notification_templates"
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
+    event_type: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
+    name: Mapped[str] = mapped_column(String(100), nullable=False)
+    title_template: Mapped[str] = mapped_column(Text, nullable=False)
+    body_template: Mapped[str] = mapped_column(Text, nullable=False)
+    is_default: Mapped[bool] = mapped_column(Boolean, default=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now(), onupdate=func.now()
+    )
+
+
+# Default templates for seeding
+DEFAULT_TEMPLATES = [
+    {
+        "event_type": "print_start",
+        "name": "Print Started",
+        "title_template": "Print Started",
+        "body_template": "{printer}: {filename}\nEstimated: {estimated_time}",
+    },
+    {
+        "event_type": "print_complete",
+        "name": "Print Completed",
+        "title_template": "Print Completed",
+        "body_template": "{printer}: {filename}\nTime: {duration}\nFilament: {filament_grams}g",
+    },
+    {
+        "event_type": "print_failed",
+        "name": "Print Failed",
+        "title_template": "Print Failed",
+        "body_template": "{printer}: {filename}\nTime: {duration}\nReason: {reason}",
+    },
+    {
+        "event_type": "print_stopped",
+        "name": "Print Stopped",
+        "title_template": "Print Stopped",
+        "body_template": "{printer}: {filename}\nTime: {duration}",
+    },
+    {
+        "event_type": "print_progress",
+        "name": "Print Progress",
+        "title_template": "Print {progress}% Complete",
+        "body_template": "{printer}: {filename}\nRemaining: {remaining_time}",
+    },
+    {
+        "event_type": "printer_offline",
+        "name": "Printer Offline",
+        "title_template": "Printer Offline",
+        "body_template": "{printer} has disconnected",
+    },
+    {
+        "event_type": "printer_error",
+        "name": "Printer Error",
+        "title_template": "Printer Error: {error_type}",
+        "body_template": "{printer}\n{error_detail}",
+    },
+    {
+        "event_type": "filament_low",
+        "name": "Filament Low",
+        "title_template": "Filament Low",
+        "body_template": "{printer}: Slot {slot} at {remaining_percent}%",
+    },
+    {
+        "event_type": "maintenance_due",
+        "name": "Maintenance Due",
+        "title_template": "Maintenance Due",
+        "body_template": "{printer}:\n{items}",
+    },
+    {
+        "event_type": "test",
+        "name": "Test Notification",
+        "title_template": "BambuTrack Test",
+        "body_template": "This is a test notification. If you see this, notifications are working!",
+    },
+]

+ 166 - 0
backend/app/schemas/notification_template.py

@@ -0,0 +1,166 @@
+"""Pydantic schemas for notification templates."""
+
+from datetime import datetime
+from enum import Enum
+
+from pydantic import BaseModel, Field
+
+
+class EventType(str, Enum):
+    """Supported notification event types."""
+
+    PRINT_START = "print_start"
+    PRINT_COMPLETE = "print_complete"
+    PRINT_FAILED = "print_failed"
+    PRINT_STOPPED = "print_stopped"
+    PRINT_PROGRESS = "print_progress"
+    PRINTER_OFFLINE = "printer_offline"
+    PRINTER_ERROR = "printer_error"
+    FILAMENT_LOW = "filament_low"
+    MAINTENANCE_DUE = "maintenance_due"
+    TEST = "test"
+
+
+# Available variables for each event type
+EVENT_VARIABLES: dict[str, list[str]] = {
+    "print_start": ["printer", "filename", "estimated_time", "timestamp", "app_name"],
+    "print_complete": ["printer", "filename", "duration", "filament_grams", "timestamp", "app_name"],
+    "print_failed": ["printer", "filename", "duration", "reason", "timestamp", "app_name"],
+    "print_stopped": ["printer", "filename", "duration", "timestamp", "app_name"],
+    "print_progress": ["printer", "filename", "progress", "remaining_time", "timestamp", "app_name"],
+    "printer_offline": ["printer", "timestamp", "app_name"],
+    "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
+    "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
+    "maintenance_due": ["printer", "items", "timestamp", "app_name"],
+    "test": ["app_name", "timestamp"],
+}
+
+# Sample data for previewing templates
+SAMPLE_DATA: dict[str, dict[str, str]] = {
+    "print_start": {
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "estimated_time": "1h 23m",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "BambuTrack",
+    },
+    "print_complete": {
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "duration": "1h 18m",
+        "filament_grams": "15.2",
+        "timestamp": "2024-01-15 15:48",
+        "app_name": "BambuTrack",
+    },
+    "print_failed": {
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "duration": "0h 45m",
+        "reason": "Filament runout",
+        "timestamp": "2024-01-15 15:15",
+        "app_name": "BambuTrack",
+    },
+    "print_stopped": {
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "duration": "0h 30m",
+        "timestamp": "2024-01-15 15:00",
+        "app_name": "BambuTrack",
+    },
+    "print_progress": {
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "progress": "50",
+        "remaining_time": "0h 41m",
+        "timestamp": "2024-01-15 15:00",
+        "app_name": "BambuTrack",
+    },
+    "printer_offline": {
+        "printer": "Bambu X1C",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "BambuTrack",
+    },
+    "printer_error": {
+        "printer": "Bambu X1C",
+        "error_type": "AMS Error",
+        "error_detail": "Filament slot 1 jammed",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "BambuTrack",
+    },
+    "filament_low": {
+        "printer": "Bambu X1C",
+        "slot": "1",
+        "remaining_percent": "15",
+        "color": "Black PLA",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "BambuTrack",
+    },
+    "maintenance_due": {
+        "printer": "Bambu X1C",
+        "items": "• Nozzle cleaning (OVERDUE)\n• Carbon rod lubrication (Soon)",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "BambuTrack",
+    },
+    "test": {
+        "app_name": "BambuTrack",
+        "timestamp": "2024-01-15 14:30",
+    },
+}
+
+
+class NotificationTemplateBase(BaseModel):
+    """Base schema for notification templates."""
+
+    title_template: str = Field(..., min_length=1, max_length=200)
+    body_template: str = Field(..., min_length=1, max_length=2000)
+
+
+class NotificationTemplateUpdate(BaseModel):
+    """Schema for updating a notification template."""
+
+    title_template: str | None = Field(default=None, min_length=1, max_length=200)
+    body_template: str | None = Field(default=None, min_length=1, max_length=2000)
+
+
+class NotificationTemplateResponse(NotificationTemplateBase):
+    """Schema for notification template API responses."""
+
+    id: int
+    event_type: str
+    name: str
+    is_default: bool
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class TemplateVariableInfo(BaseModel):
+    """Information about a template variable."""
+
+    name: str
+    description: str
+
+
+class EventVariablesResponse(BaseModel):
+    """Response for available variables per event type."""
+
+    event_type: str
+    event_name: str
+    variables: list[str]
+
+
+class TemplatePreviewRequest(BaseModel):
+    """Request to preview a template with sample data."""
+
+    event_type: str
+    title_template: str
+    body_template: str
+
+
+class TemplatePreviewResponse(BaseModel):
+    """Response with rendered template preview."""
+
+    title: str
+    body: str

+ 131 - 131
backend/app/services/notification_service.py

@@ -2,6 +2,7 @@
 
 import json
 import logging
+import re
 import smtplib
 from datetime import datetime
 from email.mime.multipart import MIMEMultipart
@@ -14,8 +15,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.notification import NotificationProvider
-from backend.app.models.settings import Settings
-from backend.app.i18n import Translator
+from backend.app.models.notification_template import NotificationTemplate
 
 logger = logging.getLogger(__name__)
 
@@ -25,6 +25,7 @@ class NotificationService:
 
     def __init__(self):
         self._http_client: httpx.AsyncClient | None = None
+        self._template_cache: dict[str, NotificationTemplate] = {}
 
     async def _get_client(self) -> httpx.AsyncClient:
         """Get or create HTTP client."""
@@ -66,136 +67,77 @@ class NotificationService:
             logger.warning(f"Invalid quiet hours format for provider {provider.name}")
             return False
 
-    async def _get_notification_language(self, db: AsyncSession) -> str:
-        """Get the notification language from settings."""
+    async def _get_template(self, db: AsyncSession, event_type: str) -> NotificationTemplate | None:
+        """Get a notification template by event type."""
+        # Check cache first
+        if event_type in self._template_cache:
+            return self._template_cache[event_type]
+
         result = await db.execute(
-            select(Settings).where(Settings.key == "notification_language")
+            select(NotificationTemplate).where(NotificationTemplate.event_type == event_type)
         )
-        setting = result.scalar_one_or_none()
-        return setting.value if setting else "en"
+        template = result.scalar_one_or_none()
+
+        if template:
+            self._template_cache[event_type] = template
+
+        return template
+
+    def _render_template(self, template_str: str, variables: dict[str, Any]) -> str:
+        """Render a template string with variables. Missing variables become empty."""
+        result = template_str
+        for key, value in variables.items():
+            result = result.replace("{" + key + "}", str(value) if value is not None else "")
+        # Remove any remaining unreplaced placeholders
+        result = re.sub(r"\{[a-z_]+\}", "", result)
+        return result
 
-    def _format_duration(self, seconds: int | None, translator: Translator) -> str:
+    def _format_duration(self, seconds: int | None) -> str:
         """Format duration in seconds to human-readable string."""
         if seconds is None:
-            return translator.t("notification.unknown")
+            return "Unknown"
         hours = seconds // 3600
         minutes = (seconds % 3600) // 60
         if hours > 0:
             return f"{hours}h {minutes}m"
         return f"{minutes}m"
 
-    def _build_print_start_message(self, printer_name: str, data: dict, translator: Translator) -> tuple[str, str]:
-        """Build notification message for print start event."""
-        filename = data.get("filename", translator.t("notification.unknown"))
-        # Clean up filename
+    def _clean_filename(self, filename: str) -> str:
+        """Remove file extensions from filename."""
         if filename.endswith(".gcode.3mf"):
-            filename = filename[:-10]
+            return filename[:-10]
         elif filename.endswith(".3mf"):
-            filename = filename[:-4]
-
-        title = translator.t("notification.print_started")
-
-        estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time")
-        time_str = self._format_duration(estimated_time * 60 if estimated_time else None, translator)
-
-        message = f"{printer_name}: {filename}\n{translator.t('notification.estimated')}: {time_str}"
-        return title, message
+            return filename[:-4]
+        return filename
 
-    def _build_print_complete_message(
-        self, printer_name: str, status: str, data: dict, translator: Translator, archive_data: dict | None = None
+    async def _build_message_from_template(
+        self, db: AsyncSession, event_type: str, variables: dict[str, Any]
     ) -> tuple[str, str]:
-        """Build notification message for print complete event."""
-        filename = data.get("filename", translator.t("notification.unknown"))
-        if filename.endswith(".gcode.3mf"):
-            filename = filename[:-10]
-        elif filename.endswith(".3mf"):
-            filename = filename[:-4]
+        """Build notification title and body from template."""
+        # Add common variables
+        variables["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M")
+        variables["app_name"] = "BambuTrack"
 
-        if status == "completed":
-            title = translator.t("notification.print_completed")
-        elif status == "failed":
-            title = translator.t("notification.print_failed")
-        elif status in ("aborted", "stopped", "cancelled"):
-            title = translator.t("notification.print_stopped")
-        else:
-            title = translator.t("notification.print_ended")
+        template = await self._get_template(db, event_type)
+        if not template:
+            # Fallback to simple message
+            logger.warning(f"Template not found for event type: {event_type}")
+            return event_type.replace("_", " ").title(), str(variables)
 
-        lines = [f"{printer_name}: {filename}"]
+        title = self._render_template(template.title_template, variables)
+        body = self._render_template(template.body_template, variables)
 
-        if archive_data:
-            # Add print time if available
-            if archive_data.get("print_time_seconds"):
-                lines.append(f"{translator.t('notification.time')}: {self._format_duration(archive_data['print_time_seconds'], translator)}")
-            # Add filament used if available
-            if archive_data.get("actual_filament_grams"):
-                lines.append(f"{translator.t('notification.filament')}: {archive_data['actual_filament_grams']:.1f}g")
-            # Add failure reason if failed
-            if status == "failed" and archive_data.get("failure_reason"):
-                lines.append(f"{translator.t('notification.reason')}: {archive_data['failure_reason']}")
-
-        message = "\n".join(lines)
-        return title, message
-
-    def _build_progress_message(
-        self, printer_name: str, filename: str, progress: int, translator: Translator
-    ) -> tuple[str, str]:
-        """Build notification message for print progress milestone."""
-        if filename.endswith(".gcode.3mf"):
-            filename = filename[:-10]
-        elif filename.endswith(".3mf"):
-            filename = filename[:-4]
-
-        title = translator.t("notification.print_progress", progress=progress)
-        message = f"{printer_name}: {filename}"
-        return title, message
-
-    def _build_printer_offline_message(self, printer_name: str, translator: Translator) -> tuple[str, str]:
-        """Build notification message for printer offline event."""
-        title = translator.t("notification.printer_offline")
-        message = translator.t("notification.printer_disconnected", printer=printer_name)
-        return title, message
-
-    def _build_printer_error_message(
-        self, printer_name: str, error_type: str, translator: Translator, error_detail: str | None = None
-    ) -> tuple[str, str]:
-        """Build notification message for printer error event."""
-        title = translator.t("notification.printer_error", error_type=error_type)
-        message = f"{printer_name}"
-        if error_detail:
-            message += f"\n{error_detail}"
-        return title, message
-
-    def _build_filament_low_message(
-        self, printer_name: str, slot: int, remaining_percent: int, translator: Translator
-    ) -> tuple[str, str]:
-        """Build notification message for low filament event."""
-        title = translator.t("notification.filament_low")
-        message = translator.t("notification.slot_at_percent", printer=printer_name, slot=slot, percent=remaining_percent)
-        return title, message
-
-    def _build_maintenance_due_message(
-        self, printer_name: str, maintenance_items: list[dict], translator: Translator
-    ) -> tuple[str, str]:
-        """Build notification message for maintenance due event."""
-        title = translator.t("notification.maintenance_due")
-        lines = [f"{printer_name}:"]
-        for item in maintenance_items:
-            status = translator.t("notification.overdue") if item.get("is_due") else translator.t("notification.soon")
-            lines.append(f"• {item['name']} ({status})")
-        message = "\n".join(lines)
-        return title, message
+        return title, body
 
     async def send_test_notification(
         self, provider_type: str, config: dict[str, Any], db: AsyncSession | None = None
     ) -> tuple[bool, str]:
         """Send a test notification to verify configuration."""
-        lang = "en"
         if db:
-            lang = await self._get_notification_language(db)
-        translator = Translator(lang)
-
-        title = translator.t("notification.test_title")
-        message = translator.t("notification.test_message")
+            title, message = await self._build_message_from_template(db, "test", {})
+        else:
+            title = "BambuTrack Test"
+            message = "This is a test notification. If you see this, notifications are working!"
 
         try:
             if provider_type == "callmebot":
@@ -461,10 +403,18 @@ class NotificationService:
             logger.info(f"No notification providers configured for print_start event on printer {printer_id}")
             return
 
-        lang = await self._get_notification_language(db)
-        translator = Translator(lang)
+        filename = self._clean_filename(data.get("filename", "Unknown"))
+        estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time")
+        time_str = self._format_duration(estimated_time * 60 if estimated_time else None)
+
+        variables = {
+            "printer": printer_name,
+            "filename": filename,
+            "estimated_time": time_str,
+        }
+
         logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}")
-        title, message = self._build_print_start_message(printer_name, data, translator)
+        title, message = await self._build_message_from_template(db, "print_start", variables)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_print_complete(
@@ -478,27 +428,47 @@ class NotificationService:
     ):
         """Handle print complete event - send notifications to relevant providers."""
         logger.info(f"on_print_complete called for printer {printer_id} ({printer_name}), status={status}")
-        # Determine which event type this is
+
+        # Determine event type based on status
         if status == "completed":
             event_field = "on_print_complete"
+            event_type = "print_complete"
         elif status in ("failed",):
             event_field = "on_print_failed"
+            event_type = "print_failed"
         elif status in ("aborted", "stopped", "cancelled"):
             event_field = "on_print_stopped"
+            event_type = "print_stopped"
         else:
-            # Unknown status, default to on_print_complete
             logger.warning(f"Unknown print status '{status}', defaulting to on_print_complete")
             event_field = "on_print_complete"
+            event_type = "print_complete"
 
         providers = await self._get_providers_for_event(db, event_field, printer_id)
         if not providers:
             logger.info(f"No notification providers configured for {event_field} event on printer {printer_id}")
             return
 
-        lang = await self._get_notification_language(db)
-        translator = Translator(lang)
+        filename = self._clean_filename(data.get("filename", "Unknown"))
+
+        variables = {
+            "printer": printer_name,
+            "filename": filename,
+            "duration": "",
+            "filament_grams": "",
+            "reason": "",
+        }
+
+        if archive_data:
+            if archive_data.get("print_time_seconds"):
+                variables["duration"] = self._format_duration(archive_data["print_time_seconds"])
+            if archive_data.get("actual_filament_grams"):
+                variables["filament_grams"] = f"{archive_data['actual_filament_grams']:.1f}"
+            if status == "failed" and archive_data.get("failure_reason"):
+                variables["reason"] = archive_data["failure_reason"]
+
         logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
-        title, message = self._build_print_complete_message(printer_name, status, data, translator, archive_data)
+        title, message = await self._build_message_from_template(db, event_type, variables)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_print_progress(
@@ -508,15 +478,21 @@ class NotificationService:
         filename: str,
         progress: int,
         db: AsyncSession,
+        remaining_time: int | None = None,
     ):
         """Handle print progress milestone (25%, 50%, 75%)."""
         providers = await self._get_providers_for_event(db, "on_print_progress", printer_id)
         if not providers:
             return
 
-        lang = await self._get_notification_language(db)
-        translator = Translator(lang)
-        title, message = self._build_progress_message(printer_name, filename, progress, translator)
+        variables = {
+            "printer": printer_name,
+            "filename": self._clean_filename(filename),
+            "progress": str(progress),
+            "remaining_time": self._format_duration(remaining_time) if remaining_time else "",
+        }
+
+        title, message = await self._build_message_from_template(db, "print_progress", variables)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_printer_offline(
@@ -527,9 +503,9 @@ class NotificationService:
         if not providers:
             return
 
-        lang = await self._get_notification_language(db)
-        translator = Translator(lang)
-        title, message = self._build_printer_offline_message(printer_name, translator)
+        variables = {"printer": printer_name}
+
+        title, message = await self._build_message_from_template(db, "printer_offline", variables)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_printer_error(
@@ -545,9 +521,13 @@ class NotificationService:
         if not providers:
             return
 
-        lang = await self._get_notification_language(db)
-        translator = Translator(lang)
-        title, message = self._build_printer_error_message(printer_name, error_type, translator, error_detail)
+        variables = {
+            "printer": printer_name,
+            "error_type": error_type,
+            "error_detail": error_detail or "",
+        }
+
+        title, message = await self._build_message_from_template(db, "printer_error", variables)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_filament_low(
@@ -557,15 +537,21 @@ class NotificationService:
         slot: int,
         remaining_percent: int,
         db: AsyncSession,
+        color: str | None = None,
     ):
         """Handle low filament event."""
         providers = await self._get_providers_for_event(db, "on_filament_low", printer_id)
         if not providers:
             return
 
-        lang = await self._get_notification_language(db)
-        translator = Translator(lang)
-        title, message = self._build_filament_low_message(printer_name, slot, remaining_percent, translator)
+        variables = {
+            "printer": printer_name,
+            "slot": str(slot),
+            "remaining_percent": str(remaining_percent),
+            "color": color or "",
+        }
+
+        title, message = await self._build_message_from_template(db, "filament_low", variables)
         await self._send_to_providers(providers, title, message, db)
 
     async def on_maintenance_due(
@@ -584,12 +570,26 @@ class NotificationService:
             logger.info(f"No notification providers configured for maintenance_due event on printer {printer_id}")
             return
 
-        lang = await self._get_notification_language(db)
-        translator = Translator(lang)
+        # Format maintenance items list
+        items_list = []
+        for item in maintenance_items:
+            status = "OVERDUE" if item.get("is_due") else "Soon"
+            items_list.append(f"- {item['name']} ({status})")
+        items_str = "\n".join(items_list)
+
+        variables = {
+            "printer": printer_name,
+            "items": items_str,
+        }
+
         logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}")
-        title, message = self._build_maintenance_due_message(printer_name, maintenance_items, translator)
+        title, message = await self._build_message_from_template(db, "maintenance_due", variables)
         await self._send_to_providers(providers, title, message, db)
 
+    def clear_template_cache(self):
+        """Clear the template cache. Call this when templates are updated."""
+        self._template_cache.clear()
+
 
 # Global instance
 notification_service = NotificationService()

+ 53 - 0
frontend/src/api/client.ts

@@ -677,6 +677,40 @@ export interface EmailConfig {
   use_tls?: boolean;
 }
 
+// Notification Template types
+export interface NotificationTemplate {
+  id: number;
+  event_type: string;
+  name: string;
+  title_template: string;
+  body_template: string;
+  is_default: boolean;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface NotificationTemplateUpdate {
+  title_template?: string;
+  body_template?: string;
+}
+
+export interface EventVariablesResponse {
+  event_type: string;
+  event_name: string;
+  variables: string[];
+}
+
+export interface TemplatePreviewRequest {
+  event_type: string;
+  title_template: string;
+  body_template: string;
+}
+
+export interface TemplatePreviewResponse {
+  title: string;
+  body: string;
+}
+
 // Spoolman types
 export interface SpoolmanStatus {
   enabled: boolean;
@@ -1230,6 +1264,25 @@ export const api = {
       body: JSON.stringify(data),
     }),
 
+  // Notification Templates
+  getNotificationTemplates: () => request<NotificationTemplate[]>('/notification-templates'),
+  getNotificationTemplate: (id: number) => request<NotificationTemplate>(`/notification-templates/${id}`),
+  updateNotificationTemplate: (id: number, data: NotificationTemplateUpdate) =>
+    request<NotificationTemplate>(`/notification-templates/${id}`, {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+  resetNotificationTemplate: (id: number) =>
+    request<NotificationTemplate>(`/notification-templates/${id}/reset`, {
+      method: 'POST',
+    }),
+  getTemplateVariables: () => request<EventVariablesResponse[]>('/notification-templates/variables'),
+  previewTemplate: (data: TemplatePreviewRequest) =>
+    request<TemplatePreviewResponse>('/notification-templates/preview', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+
   // Spoolman Integration
   getSpoolmanStatus: () => request<SpoolmanStatus>('/spoolman/status'),
   connectSpoolman: () =>

+ 79 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -30,6 +30,17 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');
   const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');
 
+  // Event toggles
+  const [onPrintStart, setOnPrintStart] = useState(provider?.on_print_start ?? false);
+  const [onPrintComplete, setOnPrintComplete] = useState(provider?.on_print_complete ?? true);
+  const [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true);
+  const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true);
+  const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
+  const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
+  const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
+  const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);
+  const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
+
   // Provider-specific config
   const [config, setConfig] = useState<Record<string, string>>(
     provider?.config ? Object.fromEntries(Object.entries(provider.config).map(([k, v]) => [k, String(v)])) : {}
@@ -115,6 +126,16 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       quiet_hours_enabled: quietHoursEnabled,
       quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
       quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,
+      // Event toggles
+      on_print_start: onPrintStart,
+      on_print_complete: onPrintComplete,
+      on_print_failed: onPrintFailed,
+      on_print_stopped: onPrintStopped,
+      on_print_progress: onPrintProgress,
+      on_printer_offline: onPrinterOffline,
+      on_printer_error: onPrinterError,
+      on_filament_low: onFilamentLow,
+      on_maintenance_due: onMaintenanceDue,
     };
 
     if (isEditing) {
@@ -380,6 +401,64 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
             )}
           </div>
 
+          {/* Event Toggles */}
+          <div className="space-y-3">
+            <p className="text-sm text-bambu-gray">Notification Events</p>
+
+            {/* Print Events */}
+            <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
+              <p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">Print Events</p>
+              <div className="grid grid-cols-2 gap-2">
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Start</span>
+                  <Toggle checked={onPrintStart} onChange={setOnPrintStart} />
+                </div>
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Complete</span>
+                  <Toggle checked={onPrintComplete} onChange={setOnPrintComplete} />
+                </div>
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Failed</span>
+                  <Toggle checked={onPrintFailed} onChange={setOnPrintFailed} />
+                </div>
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Stopped</span>
+                  <Toggle checked={onPrintStopped} onChange={setOnPrintStopped} />
+                </div>
+                <div className="flex items-center justify-between col-span-2">
+                  <div>
+                    <span className="text-sm text-white">Progress</span>
+                    <span className="text-xs text-bambu-gray ml-1">(25%, 50%, 75%)</span>
+                  </div>
+                  <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
+                </div>
+              </div>
+            </div>
+
+            {/* Printer Status Events */}
+            <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
+              <p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">Printer Status</p>
+              <div className="grid grid-cols-2 gap-2">
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Offline</span>
+                  <Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
+                </div>
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Error</span>
+                  <Toggle checked={onPrinterError} onChange={setOnPrinterError} />
+                </div>
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Low Filament</span>
+                  <Toggle checked={onFilamentLow} onChange={setOnFilamentLow} />
+                </div>
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">Maintenance</span>
+                  <Toggle checked={onMaintenanceDue} onChange={setOnMaintenanceDue} />
+                </div>
+              </div>
+            </div>
+          </div>
+
           {/* Actions */}
           <div className="flex gap-3 pt-2">
             <Button

+ 7 - 4
frontend/src/components/NotificationProviderCard.tsx

@@ -90,7 +90,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
               {provider.last_success && (
                 <span className="text-xs text-bambu-green">Last sent: {new Date(provider.last_success).toLocaleDateString()}</span>
               )}
-              {provider.last_error && (
+              {/* Only show error if it's more recent than last success */}
+              {provider.last_error && provider.last_error_at && (
+                !provider.last_success || new Date(provider.last_error_at) > new Date(provider.last_success)
+              ) && (
                 <span className="text-xs text-red-400" title={provider.last_error}>Error</span>
               )}
             </div>
@@ -130,16 +133,16 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
               <span className="px-2 py-0.5 bg-gray-500/20 text-gray-400 text-xs rounded">Offline</span>
             )}
             {provider.on_printer_error && (
-              <span className="px-2 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">Error</span>
+              <span className="px-2 py-0.5 bg-rose-500/20 text-rose-400 text-xs rounded">Error</span>
             )}
             {provider.on_filament_low && (
-              <span className="px-2 py-0.5 bg-amber-500/20 text-amber-400 text-xs rounded">Low Filament</span>
+              <span className="px-2 py-0.5 bg-cyan-500/20 text-cyan-400 text-xs rounded">Low Filament</span>
             )}
             {provider.on_maintenance_due && (
               <span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">Maintenance</span>
             )}
             {provider.quiet_hours_enabled && (
-              <span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded flex items-center gap-1">
+              <span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1">
                 <Moon className="w-3 h-3" />
                 Quiet
               </span>

+ 276 - 0
frontend/src/components/NotificationTemplateEditor.tsx

@@ -0,0 +1,276 @@
+import { useState, useEffect, useRef } from 'react';
+import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { X, Save, Loader2, RotateCcw, Plus, Eye } from 'lucide-react';
+import { api } from '../api/client';
+import type { NotificationTemplate, NotificationTemplateUpdate } from '../api/client';
+import { Button } from './Button';
+
+interface NotificationTemplateEditorProps {
+  template: NotificationTemplate;
+  onClose: () => void;
+}
+
+export function NotificationTemplateEditor({ template, onClose }: NotificationTemplateEditorProps) {
+  const queryClient = useQueryClient();
+  const bodyRef = useRef<HTMLTextAreaElement>(null);
+
+  const [titleTemplate, setTitleTemplate] = useState(template.title_template);
+  const [bodyTemplate, setBodyTemplate] = useState(template.body_template);
+  const [error, setError] = useState<string | null>(null);
+  const [showPreview, setShowPreview] = useState(true);
+
+  // Fetch variables for this event type
+  const { data: variablesData } = useQuery({
+    queryKey: ['template-variables'],
+    queryFn: api.getTemplateVariables,
+  });
+
+  // Get variables for this template's event type
+  const eventVariables = variablesData?.find(v => v.event_type === template.event_type);
+
+  // Live preview
+  const { data: preview, isLoading: previewLoading } = useQuery({
+    queryKey: ['template-preview', template.event_type, titleTemplate, bodyTemplate],
+    queryFn: () => api.previewTemplate({
+      event_type: template.event_type,
+      title_template: titleTemplate,
+      body_template: bodyTemplate,
+    }),
+    enabled: showPreview && titleTemplate.length > 0 && bodyTemplate.length > 0,
+  });
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Update mutation
+  const updateMutation = useMutation({
+    mutationFn: (data: NotificationTemplateUpdate) => api.updateNotificationTemplate(template.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
+      onClose();
+    },
+    onError: (err: Error) => {
+      setError(err.message);
+    },
+  });
+
+  // Reset mutation
+  const resetMutation = useMutation({
+    mutationFn: () => api.resetNotificationTemplate(template.id),
+    onSuccess: (resetTemplate) => {
+      setTitleTemplate(resetTemplate.title_template);
+      setBodyTemplate(resetTemplate.body_template);
+      queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
+    },
+    onError: (err: Error) => {
+      setError(err.message);
+    },
+  });
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    setError(null);
+
+    if (!titleTemplate.trim()) {
+      setError('Title is required');
+      return;
+    }
+    if (!bodyTemplate.trim()) {
+      setError('Body is required');
+      return;
+    }
+
+    updateMutation.mutate({
+      title_template: titleTemplate,
+      body_template: bodyTemplate,
+    });
+  };
+
+  const insertVariable = (variable: string) => {
+    const textarea = bodyRef.current;
+    if (!textarea) return;
+
+    const start = textarea.selectionStart;
+    const end = textarea.selectionEnd;
+    const text = bodyTemplate;
+    const before = text.substring(0, start);
+    const after = text.substring(end);
+    const newValue = before + `{${variable}}` + after;
+
+    setBodyTemplate(newValue);
+
+    // Restore focus and cursor position
+    setTimeout(() => {
+      textarea.focus();
+      const newCursor = start + variable.length + 2;
+      textarea.setSelectionRange(newCursor, newCursor);
+    }, 0);
+  };
+
+  const hasChanges = titleTemplate !== template.title_template || bodyTemplate !== template.body_template;
+
+  return (
+    <div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-2xl max-h-[90vh] flex flex-col">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0">
+          <h2 className="text-lg font-semibold text-white">
+            Edit Template: {template.name}
+          </h2>
+          <button
+            onClick={onClose}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
+          >
+            <X className="w-5 h-5 text-bambu-gray" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-4 space-y-4">
+          {error && (
+            <div className="p-3 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-sm">
+              {error}
+            </div>
+          )}
+
+          {/* Title */}
+          <div>
+            <label className="block text-sm font-medium text-bambu-gray mb-1">
+              Title
+            </label>
+            <input
+              type="text"
+              value={titleTemplate}
+              onChange={(e) => setTitleTemplate(e.target.value)}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green"
+              placeholder="Notification title..."
+            />
+          </div>
+
+          {/* Body */}
+          <div>
+            <label className="block text-sm font-medium text-bambu-gray mb-1">
+              Body
+            </label>
+            <textarea
+              ref={bodyRef}
+              value={bodyTemplate}
+              onChange={(e) => setBodyTemplate(e.target.value)}
+              rows={4}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green font-mono text-sm resize-none"
+              placeholder="Notification body..."
+            />
+          </div>
+
+          {/* Available Variables */}
+          {eventVariables && (
+            <div>
+              <label className="block text-sm font-medium text-bambu-gray mb-2">
+                Available Variables
+              </label>
+              <div className="flex flex-wrap gap-2">
+                {eventVariables.variables.map((variable) => (
+                  <button
+                    key={variable}
+                    type="button"
+                    onClick={() => insertVariable(variable)}
+                    className="inline-flex items-center gap-1 px-2 py-1 bg-bambu-dark hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-xs text-bambu-gray hover:text-white transition-colors"
+                  >
+                    <Plus className="w-3 h-3" />
+                    {variable}
+                  </button>
+                ))}
+              </div>
+              <p className="text-xs text-bambu-gray/60 mt-1">
+                Click to insert at cursor position in body
+              </p>
+            </div>
+          )}
+
+          {/* Preview */}
+          <div>
+            <div className="flex items-center justify-between mb-2">
+              <label className="text-sm font-medium text-bambu-gray flex items-center gap-2">
+                <Eye className="w-4 h-4" />
+                Live Preview
+              </label>
+              <button
+                type="button"
+                onClick={() => setShowPreview(!showPreview)}
+                className="text-xs text-bambu-green hover:text-bambu-green-light"
+              >
+                {showPreview ? 'Hide' : 'Show'}
+              </button>
+            </div>
+            {showPreview && (
+              <div className="bg-bambu-dark border border-bambu-dark-tertiary rounded p-3 space-y-2">
+                {previewLoading ? (
+                  <div className="flex items-center gap-2 text-bambu-gray text-sm">
+                    <Loader2 className="w-4 h-4 animate-spin" />
+                    Loading preview...
+                  </div>
+                ) : preview ? (
+                  <>
+                    <div>
+                      <span className="text-xs text-bambu-gray">Title:</span>
+                      <div className="text-white font-medium">{preview.title}</div>
+                    </div>
+                    <div>
+                      <span className="text-xs text-bambu-gray">Body:</span>
+                      <div className="text-white whitespace-pre-wrap text-sm">{preview.body}</div>
+                    </div>
+                  </>
+                ) : (
+                  <div className="text-bambu-gray text-sm">
+                    Enter template content to see preview
+                  </div>
+                )}
+              </div>
+            )}
+          </div>
+        </form>
+
+        {/* Footer */}
+        <div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary shrink-0">
+          <Button
+            type="button"
+            variant="ghost"
+            onClick={() => resetMutation.mutate()}
+            disabled={resetMutation.isPending}
+            className="text-orange-400 hover:text-orange-300"
+          >
+            {resetMutation.isPending ? (
+              <Loader2 className="w-4 h-4 animate-spin mr-2" />
+            ) : (
+              <RotateCcw className="w-4 h-4 mr-2" />
+            )}
+            Reset to Default
+          </Button>
+
+          <div className="flex gap-2">
+            <Button type="button" variant="secondary" onClick={onClose}>
+              Cancel
+            </Button>
+            <Button
+              onClick={handleSubmit}
+              disabled={updateMutation.isPending || !hasChanges}
+            >
+              {updateMutation.isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin mr-2" />
+              ) : (
+                <Save className="w-4 h-4 mr-2" />
+              )}
+              Save
+            </Button>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 145 - 74
frontend/src/pages/SettingsPage.tsx

@@ -1,14 +1,15 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2 } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
-import type { AppSettings, SmartPlug, NotificationProvider, UpdateStatus } from '../api/client';
+import type { AppSettings, SmartPlug, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Button } from '../components/Button';
 import { SmartPlugCard } from '../components/SmartPlugCard';
 import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
 import { NotificationProviderCard } from '../components/NotificationProviderCard';
 import { AddNotificationModal } from '../components/AddNotificationModal';
+import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { availableLanguages } from '../i18n';
@@ -24,6 +25,7 @@ export function SettingsPage() {
   const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
   const [showNotificationModal, setShowNotificationModal] = useState(false);
   const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
+  const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
   const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications'>('general');
 
@@ -52,6 +54,11 @@ export function SettingsPage() {
     queryFn: api.getNotificationProviders,
   });
 
+  const { data: notificationTemplates, isLoading: templatesLoading } = useQuery({
+    queryKey: ['notification-templates'],
+    queryFn: api.getNotificationTemplates,
+  });
+
   const { data: ffmpegStatus } = useQuery({
     queryKey: ['ffmpeg-status'],
     queryFn: api.checkFfmpeg,
@@ -741,88 +748,144 @@ export function SettingsPage() {
 
       {/* Notifications Tab */}
       {activeTab === 'notifications' && (
-        <div className="max-w-4xl">
-          <div className="flex items-center justify-between mb-6">
-            <div>
+        <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
+          {/* Left Column: Providers */}
+          <div>
+            <div className="flex items-center justify-between mb-4">
               <h2 className="text-lg font-semibold text-white flex items-center gap-2">
                 <Bell className="w-5 h-5 text-bambu-green" />
-                Notifications
+                Providers
               </h2>
-              <p className="text-sm text-bambu-gray mt-1">
-                Get notified about print events via WhatsApp, Telegram, Email, Discord, and more.
-              </p>
+              <Button
+                size="sm"
+                onClick={() => {
+                  setEditingProvider(null);
+                  setShowNotificationModal(true);
+                }}
+              >
+                <Plus className="w-4 h-4" />
+                Add
+              </Button>
             </div>
-            <Button
-              onClick={() => {
-                setEditingProvider(null);
-                setShowNotificationModal(true);
-              }}
-            >
-              <Plus className="w-4 h-4" />
-              Add Provider
-            </Button>
-          </div>
 
-          {/* Notification Language Setting */}
-          <Card className="mb-6">
-            <CardContent className="py-4">
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-white font-medium">{t('settings.notificationLanguage')}</p>
-                  <p className="text-sm text-bambu-gray">{t('settings.notificationLanguageDescription')}</p>
+            {/* Notification Language Setting */}
+            <Card className="mb-4">
+              <CardContent className="py-3">
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-white text-sm font-medium">{t('settings.notificationLanguage')}</p>
+                    <p className="text-xs text-bambu-gray">{t('settings.notificationLanguageDescription')}</p>
+                  </div>
+                  <select
+                    value={localSettings.notification_language || 'en'}
+                    onChange={(e) => updateSetting('notification_language', e.target.value)}
+                    className="px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-bambu-green"
+                  >
+                    {availableLanguages.map((lang) => (
+                      <option key={lang.code} value={lang.code}>
+                        {lang.nativeName}
+                      </option>
+                    ))}
+                  </select>
                 </div>
-                <select
-                  value={localSettings.notification_language || 'en'}
-                  onChange={(e) => updateSetting('notification_language', e.target.value)}
-                  className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green"
-                >
-                  {availableLanguages.map((lang) => (
-                    <option key={lang.code} value={lang.code}>
-                      {lang.nativeName}
-                    </option>
-                  ))}
-                </select>
-              </div>
-            </CardContent>
-          </Card>
+              </CardContent>
+            </Card>
 
-          {providersLoading ? (
-            <div className="flex justify-center py-12">
-              <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
-            </div>
-          ) : notificationProviders && notificationProviders.length > 0 ? (
-            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
-              {notificationProviders.map((provider) => (
-                <NotificationProviderCard
-                  key={provider.id}
-                  provider={provider}
-                  onEdit={(p) => {
-                    setEditingProvider(p);
-                    setShowNotificationModal(true);
-                  }}
-                />
-              ))}
-            </div>
-          ) : (
-            <Card>
-              <CardContent className="py-12">
-                <div className="text-center text-bambu-gray">
-                  <Bell className="w-16 h-16 mx-auto mb-4 opacity-30" />
-                  <p className="text-lg font-medium text-white mb-2">No notification providers configured</p>
-                  <p className="text-sm mb-4">Add a notification provider to receive alerts about your print jobs.</p>
-                  <Button
-                    onClick={() => {
-                      setEditingProvider(null);
+            {providersLoading ? (
+              <div className="flex justify-center py-12">
+                <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+              </div>
+            ) : notificationProviders && notificationProviders.length > 0 ? (
+              <div className="space-y-3">
+                {notificationProviders.map((provider) => (
+                  <NotificationProviderCard
+                    key={provider.id}
+                    provider={provider}
+                    onEdit={(p) => {
+                      setEditingProvider(p);
                       setShowNotificationModal(true);
                     }}
+                  />
+                ))}
+              </div>
+            ) : (
+              <Card>
+                <CardContent className="py-8">
+                  <div className="text-center text-bambu-gray">
+                    <Bell className="w-12 h-12 mx-auto mb-3 opacity-30" />
+                    <p className="text-sm font-medium text-white mb-2">No providers configured</p>
+                    <p className="text-xs mb-3">Add a provider to receive alerts.</p>
+                    <Button
+                      size="sm"
+                      onClick={() => {
+                        setEditingProvider(null);
+                        setShowNotificationModal(true);
+                      }}
+                    >
+                      <Plus className="w-4 h-4" />
+                      Add Provider
+                    </Button>
+                  </div>
+                </CardContent>
+              </Card>
+            )}
+          </div>
+
+          {/* Right Column: Templates */}
+          <div>
+            <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-4">
+              <FileText className="w-5 h-5 text-bambu-green" />
+              Message Templates
+            </h2>
+            <p className="text-sm text-bambu-gray mb-4">
+              Customize notification messages for each event.
+            </p>
+
+            {templatesLoading ? (
+              <div className="flex justify-center py-8">
+                <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+              </div>
+            ) : notificationTemplates && notificationTemplates.length > 0 ? (
+              <div className="space-y-2">
+                {notificationTemplates.map((template) => (
+                  <Card
+                    key={template.id}
+                    className="cursor-pointer hover:border-bambu-green/50 transition-colors"
+                    onClick={() => setEditingTemplate(template)}
                   >
-                    <Plus className="w-4 h-4" />
-                    Add Your First Provider
-                  </Button>
-                </div>
-              </CardContent>
-            </Card>
-          )}
+                    <CardContent className="py-2.5 px-3">
+                      <div className="flex items-center justify-between">
+                        <div className="min-w-0 flex-1">
+                          <p className="text-white font-medium text-sm truncate">{template.name}</p>
+                          <p className="text-bambu-gray text-xs truncate mt-0.5">
+                            {template.title_template}
+                          </p>
+                        </div>
+                        <button
+                          className="p-1.5 hover:bg-bambu-dark-tertiary rounded transition-colors shrink-0 ml-2"
+                          onClick={(e) => {
+                            e.stopPropagation();
+                            setEditingTemplate(template);
+                          }}
+                        >
+                          <Edit2 className="w-4 h-4 text-bambu-gray" />
+                        </button>
+                      </div>
+                    </CardContent>
+                  </Card>
+                ))}
+              </div>
+            ) : (
+              <Card>
+                <CardContent className="py-8">
+                  <div className="text-center text-bambu-gray">
+                    <FileText className="w-12 h-12 mx-auto mb-3 opacity-30" />
+                    <p className="text-sm">No templates available. Restart the backend to seed default templates.</p>
+                  </div>
+                </CardContent>
+              </Card>
+            )}
+          </div>
         </div>
       )}
 
@@ -847,6 +910,14 @@ export function SettingsPage() {
           }}
         />
       )}
+
+      {/* Template Editor Modal */}
+      {editingTemplate && (
+        <NotificationTemplateEditor
+          template={editingTemplate}
+          onClose={() => setEditingTemplate(null)}
+        />
+      )}
     </div>
   );
 }

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-6YKRUSex.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BP1T2ML7.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BomzirkH.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-C99F9s0L.js


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-6YKRUSex.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BP1T2ML7.css">
+    <script type="module" crossorigin src="/assets/index-C99F9s0L.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BomzirkH.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff