Explorar el Código

Feature: Advanced Authentication User Email Notifications (#693)

Feature: Advanced Authentication User Email Notifications (#693)
Thomas Rambach hace 2 meses
padre
commit
9562d66b6b

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

@@ -46,6 +46,11 @@ EVENT_NAMES = {
     # User management
     "user_created": "Welcome Email",
     "password_reset": "Password Reset",
+    # User email print notifications
+    "user_print_start": "User Print Started Email",
+    "user_print_complete": "User Print Completed Email",
+    "user_print_failed": "User Print Failed Email",
+    "user_print_stopped": "User Print Stopped Email",
 }
 
 

+ 11 - 0
backend/app/api/routes/print_queue.py

@@ -783,6 +783,17 @@ async def stop_queue_item(
     except Exception as e:
         logger.error("Error sending stop command for queue item %s: %s", item_id, e)
 
+    # Mark this printer as user-stopped BEFORE the first await so that if the
+    # MQTT on_print_complete callback fires during the db.commit() yield the flag
+    # is already set and the "failed" status will be correctly overridden to
+    # "cancelled" (preventing a spurious "print failed" notification).
+    try:
+        from backend.app.main import mark_printer_stopped_by_user
+
+        mark_printer_stopped_by_user(printer_id)
+    except Exception as _mark_err:
+        logger.warning("Failed to mark printer %s as user-stopped: %s", printer_id, _mark_err)
+
     # Update queue item status regardless - if printer is off, print is already stopped
     item.status = "cancelled"
     item.completed_at = datetime.now(timezone.utc)

+ 1 - 0
backend/app/api/routes/settings.py

@@ -98,6 +98,7 @@ async def get_settings(
                 "ha_enabled",
                 "per_printer_mapping_expanded",
                 "prometheus_enabled",
+                "user_notifications_enabled",
                 "queue_drying_enabled",
                 "queue_drying_block",
                 "ambient_drying_enabled",

+ 107 - 0
backend/app/api/routes/user_notifications.py

@@ -0,0 +1,107 @@
+"""API routes for user email notification preferences."""
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+from backend.app.models.user_email_pref import UserEmailPreference
+from backend.app.schemas.user_notifications import UserEmailPreferenceResponse, UserEmailPreferenceUpdate
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/user-notifications", tags=["user-notifications"])
+
+
+@router.get("/preferences", response_model=UserEmailPreferenceResponse)
+async def get_user_email_preferences(
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the current user's email notification preferences.
+
+    Returns defaults (all enabled) if no preferences are saved yet.
+    """
+    if current_user is None:
+        # Auth is disabled; no user context available, return defaults
+        return UserEmailPreferenceResponse(
+            notify_print_start=True,
+            notify_print_complete=True,
+            notify_print_failed=True,
+            notify_print_stopped=True,
+        )
+
+    result = await db.execute(
+        select(UserEmailPreference).where(UserEmailPreference.user_id == current_user.id)
+    )
+    pref = result.scalar_one_or_none()
+
+    if pref is None:
+        # Return defaults
+        return UserEmailPreferenceResponse(
+            notify_print_start=True,
+            notify_print_complete=True,
+            notify_print_failed=True,
+            notify_print_stopped=True,
+        )
+
+    return pref
+
+
+@router.put("/preferences", response_model=UserEmailPreferenceResponse)
+async def update_user_email_preferences(
+    data: UserEmailPreferenceUpdate,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Update the current user's email notification preferences."""
+    if current_user is None:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Authentication must be enabled to save user notification preferences",
+        )
+
+    if not current_user.email:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="User must have an email address to receive notifications",
+        )
+
+    result = await db.execute(
+        select(UserEmailPreference).where(UserEmailPreference.user_id == current_user.id)
+    )
+    pref = result.scalar_one_or_none()
+
+    if pref is None:
+        pref = UserEmailPreference(
+            user_id=current_user.id,
+            notify_print_start=data.notify_print_start,
+            notify_print_complete=data.notify_print_complete,
+            notify_print_failed=data.notify_print_failed,
+            notify_print_stopped=data.notify_print_stopped,
+        )
+        db.add(pref)
+    else:
+        pref.notify_print_start = data.notify_print_start
+        pref.notify_print_complete = data.notify_print_complete
+        pref.notify_print_failed = data.notify_print_failed
+        pref.notify_print_stopped = data.notify_print_stopped
+
+    await db.commit()
+    await db.refresh(pref)
+
+    logger.info(
+        "Updated email notification preferences for user %s: start=%s, complete=%s, failed=%s, stopped=%s",
+        current_user.username,
+        pref.notify_print_start,
+        pref.notify_print_complete,
+        pref.notify_print_failed,
+        pref.notify_print_stopped,
+    )
+
+    return pref

+ 2 - 1
backend/app/api/routes/users.py

@@ -278,7 +278,8 @@ async def update_user(
         user.groups = list(groups)
 
     await db.commit()
-    await db.refresh(user)
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
+    user = result.scalar_one()
 
     return _user_to_response(user)
 

+ 47 - 0
backend/app/core/database.py

@@ -110,6 +110,7 @@ async def init_db():
         spool_usage_history,
         spoolbuddy_device,
         user,
+        user_email_pref,
         virtual_printer,
     )
 
@@ -1455,6 +1456,52 @@ async def run_migrations(conn):
     for key in obsolete_keys:
         await conn.execute(text("DELETE FROM settings WHERE key = :key"), {"key": key})
 
+    # Migration: Create user_email_preferences table for user-specific email notification settings
+    try:
+        await conn.execute(
+            text("""
+            CREATE TABLE IF NOT EXISTS user_email_preferences (
+                id INTEGER PRIMARY KEY,
+                user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
+                notify_print_start BOOLEAN NOT NULL DEFAULT 1,
+                notify_print_complete BOOLEAN NOT NULL DEFAULT 1,
+                notify_print_failed BOOLEAN NOT NULL DEFAULT 1,
+                notify_print_stopped BOOLEAN NOT NULL DEFAULT 1,
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+        """)
+        )
+        await conn.execute(
+            text("CREATE INDEX IF NOT EXISTS ix_user_email_preferences_user_id ON user_email_preferences(user_id)")
+        )
+    except OperationalError:
+        pass  # Already applied
+
+    # Legacy migration: Add notify_print_stopped column (for any existing partial tables)
+    try:
+        await conn.execute(
+            text(
+                "ALTER TABLE user_email_preferences ADD COLUMN notify_print_stopped BOOLEAN NOT NULL DEFAULT 1"
+            )
+        )
+    except OperationalError:
+        pass  # Column already exists or table created with full schema
+
+    # Seed default settings keys that must exist on fresh install
+    default_settings = [
+        ("advanced_auth_enabled", "false"),
+        ("smtp_auth_enabled", "true"),
+    ]
+    for key, value in default_settings:
+        try:
+            await conn.execute(
+                text("INSERT OR IGNORE INTO settings (key, value) VALUES (:key, :value)"),
+                {"key": key, "value": value},
+            )
+        except OperationalError:
+            pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 3 - 1
backend/app/core/permissions.py

@@ -97,7 +97,7 @@ class Permission(StrEnum):
     NOTIFICATIONS_CREATE = "notifications:create"
     NOTIFICATIONS_UPDATE = "notifications:update"
     NOTIFICATIONS_DELETE = "notifications:delete"
-
+    NOTIFICATIONS_USER_EMAIL = "notifications:user_email"  # Receive per-user print email notifications
     # Notification Templates
     NOTIFICATION_TEMPLATES_READ = "notification_templates:read"
     NOTIFICATION_TEMPLATES_UPDATE = "notification_templates:update"
@@ -244,6 +244,7 @@ PERMISSION_CATEGORIES = {
         Permission.NOTIFICATIONS_CREATE,
         Permission.NOTIFICATIONS_UPDATE,
         Permission.NOTIFICATIONS_DELETE,
+        Permission.NOTIFICATIONS_USER_EMAIL,
         Permission.NOTIFICATION_TEMPLATES_READ,
         Permission.NOTIFICATION_TEMPLATES_UPDATE,
     ],
@@ -381,6 +382,7 @@ DEFAULT_GROUPS = {
             Permission.NOTIFICATIONS_CREATE.value,
             Permission.NOTIFICATIONS_UPDATE.value,
             Permission.NOTIFICATIONS_DELETE.value,
+            Permission.NOTIFICATIONS_USER_EMAIL.value,
             Permission.NOTIFICATION_TEMPLATES_READ.value,
             Permission.NOTIFICATION_TEMPLATES_UPDATE.value,
             # External Links - full access

+ 327 - 5
backend/app/main.py

@@ -45,6 +45,7 @@ from backend.app.api.routes import (
     support,
     system,
     updates,
+    user_notifications,
     users,
     virtual_printers,
     webhook,
@@ -280,6 +281,28 @@ _timelapse_baselines: dict[int, set[str]] = {}
 # Track active bed cooldown monitoring tasks: {printer_id: asyncio.Task}
 _bed_cooldown_tasks: dict[int, asyncio.Task] = {}
 
+# Track printers where the user explicitly stopped the print from the queue UI.
+# When on_print_complete fires with status "failed" for these printers we treat it
+# as "cancelled" (stopped by user) so the correct notification email is sent.
+_user_stopped_printers: set[int] = set()
+
+# Track created_by_id for expected prints so the user email can be sent even when
+# the archive itself doesn't have created_by_id set (e.g. library-file-based prints).
+# {(printer_id, filename): created_by_id}
+_expected_print_creators: dict[tuple[int, str], int] = {}
+
+# TTL for expected-print entries: evict registrations older than this to prevent
+# unbounded growth when a print is registered but never starts (e.g. printer
+# disconnect, app restart, print started from the printer panel).
+_EXPECTED_PRINT_TTL_SECONDS: int = 2 * 60 * 60  # 2 hours
+
+# Registration timestamps used for TTL eviction: {(printer_id, filename): monotonic_time}
+_expected_print_registered_at: dict[tuple[int, str], float] = {}
+
+# Cleanup loop interval
+_EXPECTED_PRINT_CLEANUP_INTERVAL: int = 15 * 60  # 15 minutes
+_expected_prints_cleanup_task: asyncio.Task | None = None
+
 
 async def _get_plug_energy(plug, db) -> dict | None:
     """Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT).
@@ -308,7 +331,13 @@ async def _get_plug_energy(plug, db) -> dict | None:
         return await tasmota_service.get_energy(plug)
 
 
-def register_expected_print(printer_id: int, filename: str, archive_id: int, ams_mapping: list[int] | None = None):
+def register_expected_print(
+    printer_id: int,
+    filename: str,
+    archive_id: int,
+    ams_mapping: list[int] | None = None,
+    created_by_id: int | None = None,
+):
     """Register an expected print from reprint/scheduled so we don't create duplicate archives."""
     # Store with multiple filename variations to catch different naming patterns
     _expected_prints[(printer_id, filename)] = archive_id
@@ -320,6 +349,21 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int, ams
     # Store AMS mapping for usage tracking at print completion
     if ams_mapping is not None:
         _print_ams_mappings[archive_id] = ams_mapping
+    # Store created_by_id so the user start email can be sent even when the archive
+    # itself has no created_by_id (e.g. library-file-based queue prints)
+    if created_by_id is not None:
+        _expected_print_creators[(printer_id, filename)] = created_by_id
+        if filename.endswith(".3mf"):
+            base = filename[:-4]
+            _expected_print_creators[(printer_id, base)] = created_by_id
+            _expected_print_creators[(printer_id, f"{base}.gcode")] = created_by_id
+    # Record registration time for TTL-based eviction
+    _registered_at = time.monotonic()
+    _expected_print_registered_at[(printer_id, filename)] = _registered_at
+    if filename.endswith(".3mf"):
+        base = filename[:-4]
+        _expected_print_registered_at[(printer_id, base)] = _registered_at
+        _expected_print_registered_at[(printer_id, f"{base}.gcode")] = _registered_at
     logging.getLogger(__name__).info(
         f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}, ams_mapping={ams_mapping}"
     )
@@ -332,6 +376,15 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No
         stored_ams_mapping = _print_ams_mappings.get(archive_id)
     return stored_ams_mapping
 
+def mark_printer_stopped_by_user(printer_id: int) -> None:
+    """Mark that the active print on this printer was stopped by the user from the queue UI.
+
+    When on_print_complete fires with status 'failed' for a printer in this set we
+    reclassify it as 'cancelled' so the correct 'print stopped' notification is sent
+    rather than a 'print failed' notification.
+    """
+    _user_stopped_printers.add(printer_id)
+    logging.getLogger(__name__).info("Marked printer %s as user-stopped from queue", printer_id)
 
 _last_status_broadcast: dict[int, str] = {}
 # Track printers where we've updated nozzle_count
@@ -1035,10 +1088,55 @@ async def _send_print_start_notification(
                 archive_data["image_data"] = image_data
 
             await notification_service.on_print_start(printer_id, printer_name, data, db, archive_data=archive_data)
+
+            # Send user-specific email notification for print start
+            if archive_data and archive_data.get("created_by_id"):
+                await notification_service.send_user_print_email(
+                    event_type="user_print_start",
+                    created_by_id=archive_data["created_by_id"],
+                    printer_name=printer_name,
+                    filename=data.get("subtask_name") or data.get("filename", "Unknown"),
+                    db=db,
+                )
     except Exception as e:
         logger.warning("Notification on_print_start failed: %s", e)
 
 
+async def _dispatch_user_print_email(
+    status: str,
+    created_by_id: int | None,
+    printer_name: str,
+    filename: str,
+    db,
+) -> None:
+    """Send a user-specific print-completion email based on print status.
+
+    Maps the normalised print status to the correct event type and delegates
+    to :meth:`NotificationService.send_user_print_email`.  A single helper
+    avoids duplicating the ``if status == "completed" / elif "failed" / elif
+    "stopped"`` dispatch block at every call site.
+
+    Does nothing if *created_by_id* is ``None``.
+    """
+    if created_by_id is None:
+        return
+    if status == "completed":
+        event_type = "user_print_complete"
+    elif status == "failed":
+        event_type = "user_print_failed"
+    elif status in ("stopped", "aborted", "cancelled"):
+        event_type = "user_print_stopped"
+    else:
+        return
+    await notification_service.send_user_print_email(
+        event_type=event_type,
+        created_by_id=created_by_id,
+        printer_name=printer_name,
+        filename=filename,
+        db=db,
+    )
+
+
 def _load_objects_from_archive(archive, printer_id: int, logger) -> None:
     """Extract printable objects from an archive's 3MF file and store in printer state."""
     try:
@@ -1067,6 +1165,9 @@ async def on_print_start(printer_id: int, data: dict):
 
     logger.info("[CALLBACK] on_print_start called for printer %s, data keys: %s", printer_id, list(data.keys()))
 
+    # Clear any stale user-stopped flag from previous print cycles
+    _user_stopped_printers.discard(printer_id)
+
     # Cancel any active bed cooldown task for this printer
     existing_task = _bed_cooldown_tasks.pop(printer_id, None)
     if existing_task and not existing_task.done():
@@ -1222,7 +1323,36 @@ async def on_print_start(printer_id: int, data: dict):
                 f"[CALLBACK] Skipping archive - printer: {printer is not None}, auto_archive: {printer.auto_archive if printer else 'N/A'}"
             )
             if not notification_sent:
-                await _send_print_start_notification(printer_id, data, logger=logger)
+                # Even with auto-archive disabled, try to recover created_by_id from
+                # a registered expected print (e.g. a library-file queue item) so the
+                # user start email can still be sent.
+                _fn = data.get("filename", "")
+                _sn = data.get("subtask_name", "")
+                _no_archive_creator_keys: list[tuple[int, str]] = []
+                if _sn:
+                    _no_archive_creator_keys += [
+                        (printer_id, _sn),
+                        (printer_id, f"{_sn}.3mf"),
+                        (printer_id, f"{_sn}.gcode.3mf"),
+                    ]
+                if _fn:
+                    _base_fn = _fn.split("/")[-1] if "/" in _fn else _fn
+                    _no_archive_creator_keys.append((printer_id, _base_fn))
+                    _no_archive_base = _base_fn.replace(".gcode", "").replace(".3mf", "")
+                    _no_archive_creator_keys += [
+                        (printer_id, _no_archive_base),
+                        (printer_id, f"{_no_archive_base}.3mf"),
+                    ]
+                _no_archive_creator: int | None = None
+                for _key in _no_archive_creator_keys:
+                    # Clean up all dicts for every key to avoid memory leaks
+                    _expected_prints.pop(_key, None)
+                    _expected_print_registered_at.pop(_key, None)
+                    popped_creator = _expected_print_creators.pop(_key, None)
+                    if _no_archive_creator is None:
+                        _no_archive_creator = popped_creator
+                _creator_data = {"created_by_id": _no_archive_creator} if _no_archive_creator else None
+                await _send_print_start_notification(printer_id, data, _creator_data, logger)
             return
 
         # Get the filename and subtask_name
@@ -1264,10 +1394,12 @@ async def on_print_start(printer_id: int, data: dict):
         expected_archive_id = None
         for key in expected_keys:
             expected_archive_id = _expected_prints.pop(key, None)
+            _expected_print_registered_at.pop(key, None)
             if expected_archive_id:
                 # Clean up other possible keys for this print
                 for other_key in expected_keys:
                     _expected_prints.pop(other_key, None)
+                    _expected_print_registered_at.pop(other_key, None)
                 break
 
         if expected_archive_id:
@@ -1320,7 +1452,19 @@ async def on_print_start(printer_id: int, data: dict):
 
                 # Send notification with archive data (reprint/scheduled)
                 if not notification_sent:
-                    archive_data = {"print_time_seconds": archive.print_time_seconds}
+                    # Use archive's created_by_id; fall back to the creator registered via
+                    # register_expected_print (handles library-file-based queue items where
+                    # the freshly-created archive has no created_by_id yet).
+                    # Pop ALL matching keys so no stale entries remain in the dict.
+                    fallback_creator = None
+                    for key in expected_keys:
+                        popped = _expected_print_creators.pop(key, None)
+                        if fallback_creator is None:
+                            fallback_creator = popped
+                    archive_data = {
+                        "print_time_seconds": archive.print_time_seconds,
+                        "created_by_id": archive.created_by_id or fallback_creator,
+                    }
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
 
                 # Extract printable objects from the archived 3MF file
@@ -1401,7 +1545,10 @@ async def on_print_start(printer_id: int, data: dict):
                         logger.warning("Failed to record starting energy for existing archive: %s", e)
                 # Send notification with archive data (existing archive)
                 if not notification_sent:
-                    archive_data = {"print_time_seconds": existing_archive.print_time_seconds}
+                    archive_data = {
+                        "print_time_seconds": existing_archive.print_time_seconds,
+                        "created_by_id": existing_archive.created_by_id,
+                    }
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
                 # Extract printable objects from the archived 3MF file
                 _load_objects_from_archive(existing_archive, printer_id, logger)
@@ -1749,7 +1896,10 @@ async def on_print_start(printer_id: int, data: dict):
 
                 # Send notification with archive data (new archive created)
                 if not notification_sent:
-                    archive_data = {"print_time_seconds": archive.print_time_seconds}
+                    archive_data = {
+                        "print_time_seconds": archive.print_time_seconds,
+                        "created_by_id": archive.created_by_id,
+                    }
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
 
                 # Extract printable objects for skip object functionality
@@ -2060,6 +2210,20 @@ async def on_print_complete(printer_id: int, data: dict):
     # Clear current print user tracking (Issue #206)
     printer_manager.clear_current_print_user(printer_id)
 
+    # If the user explicitly stopped this print from the queue UI the printer will
+    # report "failed" or "aborted" via MQTT.  Override that to "cancelled" so the
+    # correct "print stopped" notification/email is sent instead of a failure alert.
+    _raw_status = data.get("status", "completed")
+    if printer_id in _user_stopped_printers and _raw_status in ("failed", "aborted"):
+        logger.info(
+            "[CALLBACK] Overriding status '%s' -> 'cancelled' for printer %s "
+            "(print was stopped from queue by user)",
+            _raw_status,
+            printer_id,
+        )
+        data = {**data, "status": "cancelled"}
+    _user_stopped_printers.discard(printer_id)
+
     # MQTT relay - publish print complete
     try:
         printer_info = printer_manager.get_printer(printer_id)
@@ -2421,6 +2585,76 @@ async def on_print_complete(printer_id: int, data: dict):
 
     if not archive_id:
         logger.warning("Could not find archive for print complete: filename=%s, subtask=%s", filename, subtask_name)
+
+        # Still send print-complete/failed/stopped notifications even without an archive.
+        # Try to enrich with queue/library-file data so user-specific emails work too.
+        async def _notify_no_archive():
+            try:
+                async with async_session() as db:
+                    from backend.app.models.library import LibraryFile
+                    from backend.app.models.print_queue import PrintQueueItem
+                    from backend.app.models.printer import Printer
+
+                    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+                    printer_obj = result.scalar_one_or_none()
+                    p_name = printer_obj.name if printer_obj else f"Printer {printer_id}"
+
+                    # Try to find the most-recent queue item for this printer so we can
+                    # recover created_by_id and estimated print time.
+                    # NOTE: By the time this task runs the queue item status has already
+                    # been updated to a terminal state (completed/failed/cancelled), so
+                    # we look for recently-completed items (within the last 5 minutes).
+                    no_archive_data: dict | None = None
+                    try:
+                        cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
+                        q_result = await db.execute(
+                            select(PrintQueueItem)
+                            .where(PrintQueueItem.printer_id == printer_id)
+                            .where(PrintQueueItem.status.in_(["completed", "failed", "cancelled"]))
+                            .where(PrintQueueItem.completed_at >= cutoff)
+                            .order_by(PrintQueueItem.completed_at.desc())
+                            .limit(1)
+                        )
+                        queue_item = q_result.scalar_one_or_none()
+                        if queue_item:
+                            no_archive_data = {"created_by_id": queue_item.created_by_id}
+                            # Pull estimated time from library file when available
+                            if queue_item.library_file_id:
+                                lib_result = await db.execute(
+                                    select(LibraryFile).where(LibraryFile.id == queue_item.library_file_id)
+                                )
+                                lib_file = lib_result.scalar_one_or_none()
+                                if lib_file and lib_file.print_time_seconds:
+                                    no_archive_data["print_time_seconds"] = lib_file.print_time_seconds
+                    except Exception as lookup_err:
+                        logger.debug(
+                            "[NOTIFY-BG] Could not look up queue item for no-archive notification: %s", lookup_err
+                        )
+
+                    ps = data.get("status", "completed")
+                    logger.info(
+                        "[NOTIFY-BG] Sending notification without archive: printer=%s, status=%s", printer_id, ps
+                    )
+                    await notification_service.on_print_complete(
+                        printer_id, p_name, ps, data, db, archive_data=no_archive_data
+                    )
+
+                    # Send user-specific email if we have a created_by_id
+                    if no_archive_data and no_archive_data.get("created_by_id"):
+                        raw_filename = data.get("subtask_name") or data.get("filename", "Unknown")
+                        await _dispatch_user_print_email(
+                            ps,
+                            no_archive_data["created_by_id"],
+                            p_name,
+                            raw_filename,
+                            db,
+                        )
+                    logger.info("[NOTIFY-BG] Completed (no-archive path)")
+            except Exception as e:
+                logger.warning("[NOTIFY-BG] Failed to send notification without archive: %s", e, exc_info=True)
+
+        task = asyncio.create_task(_notify_no_archive())
+        task.add_done_callback(lambda t: None)
         return
 
     log_timing("Archive lookup")
@@ -2760,6 +2994,7 @@ async def on_print_complete(printer_id: int, data: dict):
                             "print_time_seconds": archive.print_time_seconds,
                             "actual_filament_grams": archive.filament_used_grams,
                             "failure_reason": archive.failure_reason,
+                            "created_by_id": archive.created_by_id,
                         }
 
                         # Scale filament usage for partial prints
@@ -2822,6 +3057,19 @@ async def on_print_complete(printer_id: int, data: dict):
                 await notification_service.on_print_complete(
                     printer_id, printer_name, print_status, data, db, archive_data=archive_data
                 )
+
+                # Send user-specific email notification
+                if archive_data:
+                    created_by_id = archive_data.get("created_by_id")
+                    raw_filename = data.get("subtask_name") or data.get("filename", "Unknown")
+                    await _dispatch_user_print_email(
+                        print_status,
+                        created_by_id,
+                        printer_name,
+                        raw_filename,
+                        db,
+                    )
+
                 logger.info("[NOTIFY-BG] Completed")
         except Exception as e:
             logger.warning("[NOTIFY-BG] Failed: %s", e)
@@ -3315,6 +3563,74 @@ def stop_camera_cleanup():
         logging.getLogger(__name__).info("Camera stream cleanup stopped")
 
 
+# ---------------------------------------------------------------------------
+# Expected-print TTL eviction
+# ---------------------------------------------------------------------------
+
+
+def _evict_stale_expected_prints() -> None:
+    """Remove entries from _expected_prints / _expected_print_creators that are
+    older than _EXPECTED_PRINT_TTL_SECONDS.
+
+    This prevents unbounded growth when a print is registered (via
+    register_expected_print) but on_print_start never fires — e.g. because the
+    printer disconnects, the app restarts, or the print is started directly from
+    the printer panel without going through the queue.
+    """
+    # Use monotonic time so the TTL is unaffected by system clock adjustments
+    # (e.g. NTP sync, DST changes).
+    cutoff = time.monotonic() - _EXPECTED_PRINT_TTL_SECONDS
+    stale_keys = [k for k, t in _expected_print_registered_at.items() if t < cutoff]
+    if not stale_keys:
+        return
+
+    evicted_archive_ids: set[int] = set()
+    for key in stale_keys:
+        archive_id = _expected_prints.pop(key, None)
+        if archive_id is not None:
+            evicted_archive_ids.add(archive_id)
+        _expected_print_creators.pop(key, None)
+        _expected_print_registered_at.pop(key, None)
+
+    # Also clean up _print_ams_mappings for archive_ids that have no remaining
+    # live keys in _expected_prints (i.e. all variants were just evicted).
+    live_archive_ids = set(_expected_prints.values())
+    for archive_id in evicted_archive_ids:
+        if archive_id not in live_archive_ids:
+            _print_ams_mappings.pop(archive_id, None)
+
+    logging.getLogger(__name__).info(
+        "Evicted %d stale expected-print entries (TTL=%ds)", len(stale_keys), _EXPECTED_PRINT_TTL_SECONDS
+    )
+
+
+async def _expected_prints_cleanup_loop() -> None:
+    """Background task: periodically evict stale expected-print entries."""
+    while True:
+        try:
+            _evict_stale_expected_prints()
+        except asyncio.CancelledError:
+            raise
+        except Exception as e:
+            logging.getLogger(__name__).warning("Expected prints cleanup failed: %s", e)
+        await asyncio.sleep(_EXPECTED_PRINT_CLEANUP_INTERVAL)
+
+
+def start_expected_prints_cleanup() -> None:
+    global _expected_prints_cleanup_task
+    if _expected_prints_cleanup_task is None:
+        _expected_prints_cleanup_task = asyncio.create_task(_expected_prints_cleanup_loop())
+        logging.getLogger(__name__).info("Expected prints cleanup started")
+
+
+def stop_expected_prints_cleanup() -> None:
+    global _expected_prints_cleanup_task
+    if _expected_prints_cleanup_task:
+        _expected_prints_cleanup_task.cancel()
+        _expected_prints_cleanup_task = None
+        logging.getLogger(__name__).info("Expected prints cleanup stopped")
+
+
 @asynccontextmanager
 async def lifespan(app: FastAPI):
     # Startup
@@ -3469,6 +3785,10 @@ async def lifespan(app: FastAPI):
     # Start camera stream orphan cleanup
     start_camera_cleanup()
 
+    # Start expected-print TTL eviction (prevents memory leak when prints are
+    # registered but on_print_start never fires)
+    start_expected_prints_cleanup()
+
     # Initialize virtual printer manager and sync from DB
     from backend.app.services.virtual_printer import virtual_printer_manager
 
@@ -3491,6 +3811,7 @@ async def lifespan(app: FastAPI):
     stop_runtime_tracking()
     stop_spoolbuddy_watchdog()
     stop_camera_cleanup()
+    stop_expected_prints_cleanup()
     printer_manager.disconnect_all()
     await close_spoolman_client()
 
@@ -3686,6 +4007,7 @@ app.include_router(background_dispatch_routes.router, prefix=app_settings.api_pr
 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(user_notifications.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)

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

@@ -170,4 +170,29 @@ DEFAULT_TEMPLATES = [
         "title_template": "{app_name} - Password Reset",
         "body_template": "Hello {username},\n\nYour password has been reset.\nNew Password: {password}\n\nLogin at: {login_url}",
     },
+    # User email notification templates (sent to the print job owner)
+    {
+        "event_type": "user_print_start",
+        "name": "User Print Started",
+        "title_template": "Your Print Has Started",
+        "body_template": "Hello {username},\n\nYour print job has started on {printer}.\n\nFile: {filename}\n\nYou will be notified when it completes.",
+    },
+    {
+        "event_type": "user_print_complete",
+        "name": "User Print Completed",
+        "title_template": "Your Print Is Complete",
+        "body_template": "Hello {username},\n\nYour print job has completed on {printer}.\n\nFile: {filename}",
+    },
+    {
+        "event_type": "user_print_failed",
+        "name": "User Print Failed",
+        "title_template": "Your Print Has Failed",
+        "body_template": "Hello {username},\n\nYour print job has failed on {printer}.\n\nFile: {filename}",
+    },
+    {
+        "event_type": "user_print_stopped",
+        "name": "User Print Stopped",
+        "title_template": "Your Print Has Been Stopped",
+        "body_template": "Hello {username},\n\nYour print job was stopped on {printer}.\n\nFile: {filename}",
+    },
 ]

+ 10 - 0
backend/app/models/user.py

@@ -10,6 +10,7 @@ from backend.app.core.database import Base
 
 if TYPE_CHECKING:
     from backend.app.models.group import Group
+    from backend.app.models.user_email_pref import UserEmailPreference
 
 
 class User(Base):
@@ -45,6 +46,15 @@ class User(Base):
         lazy="selectin",
     )
 
+    # Relationship to email notification preferences
+    email_preferences: Mapped[UserEmailPreference | None] = relationship(
+        "UserEmailPreference",
+        back_populates="user",
+        uselist=False,
+        cascade="all, delete-orphan",
+        lazy="select",
+    )
+
     @property
     def is_admin(self) -> bool:
         """Check if user is an admin.

+ 31 - 0
backend/app/models/user_email_pref.py

@@ -0,0 +1,31 @@
+"""User email notification preference model."""
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class UserEmailPreference(Base):
+    """Stores per-user email notification preferences for their own print jobs."""
+
+    __tablename__ = "user_email_preferences"
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
+    user_id: Mapped[int] = mapped_column(
+        Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True
+    )
+
+    # Print lifecycle notifications (only for jobs submitted by this user)
+    notify_print_start: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+    notify_print_complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+    notify_print_failed: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+    notify_print_stopped: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+
+    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())
+
+    # Relationship
+    user: Mapped["User"] = relationship(back_populates="email_preferences")

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

@@ -81,6 +81,11 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     # User management notifications
     "user_created": ["username", "password", "login_url", "app_name", "timestamp"],
     "password_reset": ["username", "password", "login_url", "app_name", "timestamp"],
+    # User email print notifications
+    "user_print_start": ["username", "printer", "filename", "timestamp", "app_name"],
+    "user_print_complete": ["username", "printer", "filename", "timestamp", "app_name"],
+    "user_print_failed": ["username", "printer", "filename", "timestamp", "app_name"],
+    "user_print_stopped": ["username", "printer", "filename", "timestamp", "app_name"],
 }
 
 # Sample data for previewing templates
@@ -252,6 +257,35 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",
     },
+    # User email print notifications
+    "user_print_start": {
+        "username": "john_doe",
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "user_print_complete": {
+        "username": "john_doe",
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "timestamp": "2024-01-15 15:48",
+        "app_name": "Bambuddy",
+    },
+    "user_print_failed": {
+        "username": "john_doe",
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "timestamp": "2024-01-15 15:15",
+        "app_name": "Bambuddy",
+    },
+    "user_print_stopped": {
+        "username": "john_doe",
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "timestamp": "2024-01-15 15:15",
+        "app_name": "Bambuddy",
+    },
 }
 
 

+ 7 - 0
backend/app/schemas/settings.py

@@ -178,6 +178,12 @@ class AppSettings(BaseModel):
         description="Low stock threshold percentage (%) for inventory filtering and display",
     )
 
+    # User email notifications (requires Advanced Authentication)
+    user_notifications_enabled: bool = Field(
+        default=True,
+        description="Enable user email notifications for print job events (requires Advanced Authentication)",
+    )
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -245,3 +251,4 @@ class AppSettingsUpdate(BaseModel):
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
+    user_notifications_enabled: bool | None = None

+ 24 - 0
backend/app/schemas/user_notifications.py

@@ -0,0 +1,24 @@
+"""Schemas for user email notification preferences."""
+
+from pydantic import BaseModel
+
+
+class UserEmailPreferenceResponse(BaseModel):
+    """Response schema for user email notification preferences."""
+
+    notify_print_start: bool
+    notify_print_complete: bool
+    notify_print_failed: bool
+    notify_print_stopped: bool
+
+    class Config:
+        from_attributes = True
+
+
+class UserEmailPreferenceUpdate(BaseModel):
+    """Update schema for user email notification preferences."""
+
+    notify_print_start: bool
+    notify_print_complete: bool
+    notify_print_failed: bool
+    notify_print_stopped: bool

+ 66 - 0
backend/app/services/email_service.py

@@ -8,6 +8,7 @@ import re
 import secrets
 import smtplib
 import string
+from datetime import datetime
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
 from typing import Any
@@ -514,3 +515,68 @@ async def create_password_reset_email_from_template(
         # Fallback to hardcoded template
         logger.warning("No password reset email template found in database, using default")
         return create_password_reset_email(username, password, login_url)
+
+
+async def send_user_print_notification(
+    db: AsyncSession,
+    event_type: str,
+    user_email: str,
+    username: str,
+    variables: dict,
+) -> None:
+    """Send a print notification email to a user using Advanced Auth SMTP settings.
+
+    Args:
+        db: Database session
+        event_type: One of 'user_print_start', 'user_print_complete', 'user_print_failed', 'user_print_stopped'
+        user_email: Recipient email address
+        username: Username of the recipient
+        variables: Template variables (printer, filename, etc.)
+    """
+    # Check that advanced auth is enabled (SMTP settings must be configured)
+    smtp_settings = await get_smtp_settings(db)
+    if not smtp_settings:
+        logger.warning("Cannot send user print notification: SMTP settings not configured")
+        return
+
+    # Get the template
+    template = await get_notification_template(db, event_type)
+    if template is None:
+        logger.warning("No template found for event type: %s", event_type)
+        return
+
+    # Add common variables (username, timestamp, app_name) merged with caller-supplied variables
+    all_variables = {
+        "username": username,
+        "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
+        "app_name": "Bambuddy",
+        **variables,
+    }
+
+    subject = render_template(template.title_template, all_variables)
+    text_body = render_template(template.body_template, all_variables)
+
+    # Build HTML body — content comes entirely from the database template
+    escaped_text_body = html.escape(text_body).replace("\n", "<br>\n")
+    html_body = f"""<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
+    <div style="background: linear-gradient(135deg, #1db954 0%, #158a3e 100%); background-color: #1db954; padding: 20px; border-radius: 8px 8px 0 0;">
+        <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">{html.escape(subject)}</h1>
+    </div>
+    <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
+        <div style="font-size: 16px;">{escaped_text_body}</div>
+    </div>
+</body>
+</html>
+"""
+
+    try:
+        send_email(smtp_settings, user_email, subject, text_body, html_body)
+        logger.info("Sent %s notification email to %s", event_type, user_email)
+    except Exception as e:
+        logger.error("Failed to send %s notification to %s: %s", event_type, user_email, e)

+ 114 - 0
backend/app/services/notification_service.py

@@ -1179,6 +1179,120 @@ class NotificationService:
         """Clear the template cache. Call this when templates are updated."""
         self._template_cache.clear()
 
+    async def send_user_print_email(
+        self,
+        event_type: str,
+        created_by_id: int | None,
+        printer_name: str,
+        filename: str,
+        db: AsyncSession,
+    ) -> None:
+        """Send a print event email notification to the user who submitted the job.
+
+        Args:
+            event_type: 'user_print_start', 'user_print_complete', 'user_print_failed', or 'user_print_stopped'
+            created_by_id: User ID who submitted the print job (from archive)
+            printer_name: Name of the printer
+            filename: Raw filename or subtask name
+            db: Database session
+        """
+        if created_by_id is None:
+            logger.debug("[EMAIL] Skipping user print email (%s): no created_by_id", event_type)
+            return
+
+        try:
+            # Check if advanced auth is enabled - required for user email notifications
+            from backend.app.models.settings import Settings
+
+            result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
+            setting = result.scalar_one_or_none()
+            if not setting or setting.value.lower() != "true":
+                logger.debug("[EMAIL] Skipping user print email (%s): advanced_auth not enabled", event_type)
+                return
+
+            # Check if user notifications are enabled (admin-controlled toggle)
+            notif_enabled_result = await db.execute(
+                select(Settings).where(Settings.key == "user_notifications_enabled")
+            )
+            notif_enabled_setting = notif_enabled_result.scalar_one_or_none()
+            if notif_enabled_setting and notif_enabled_setting.value.lower() == "false":
+                logger.debug("[EMAIL] Skipping user print email (%s): user_notifications_enabled is false", event_type)
+                return
+
+            # Check SMTP settings are configured - required for sending emails
+            from backend.app.services.email_service import get_smtp_settings, send_user_print_notification
+
+            smtp_settings = await get_smtp_settings(db)
+            if not smtp_settings:
+                logger.debug("[EMAIL] Skipping user print email (%s): SMTP settings not configured", event_type)
+                return
+
+            # Load user preferences
+            from backend.app.models.user import User
+            from backend.app.models.user_email_pref import UserEmailPreference
+
+            user_result = await db.execute(select(User).where(User.id == created_by_id))
+            user = user_result.scalar_one_or_none()
+            if user is None or not user.email:
+                logger.debug(
+                    "[EMAIL] Skipping user print email (%s): user %s not found or has no email address",
+                    event_type,
+                    created_by_id,
+                )
+                return
+
+            # Load user's notification preferences
+            pref_result = await db.execute(
+                select(UserEmailPreference).where(UserEmailPreference.user_id == created_by_id)
+            )
+            pref = pref_result.scalar_one_or_none()
+
+            # Determine if this event type should be sent
+            should_send = False
+            if event_type == "user_print_start":
+                should_send = pref is None or pref.notify_print_start
+            elif event_type == "user_print_complete":
+                should_send = pref is None or pref.notify_print_complete
+            elif event_type == "user_print_failed":
+                should_send = pref is None or pref.notify_print_failed
+            elif event_type == "user_print_stopped":
+                should_send = pref is None or pref.notify_print_stopped
+
+            if not should_send:
+                logger.debug(
+                    "[EMAIL] Skipping user print email (%s): user %s has notifications disabled for this event",
+                    event_type,
+                    created_by_id,
+                )
+                return
+
+            logger.info(
+                "[EMAIL] Sending user print email: event=%s, user=%s (%s), printer=%s, file=%s",
+                event_type,
+                user.username,
+                user.email,
+                printer_name,
+                filename,
+            )
+
+            # Build variables
+            variables = {
+                "printer": printer_name,
+                "filename": self._clean_filename(filename),
+            }
+
+            # Send the email
+            await send_user_print_notification(
+                db=db,
+                event_type=event_type,
+                user_email=user.email,
+                username=user.username,
+                variables=variables,
+            )
+            logger.info("[EMAIL] User print email sent: event=%s → %s", event_type, user.email)
+        except Exception as e:
+            logger.warning("Failed to send user print email notification: %s", e, exc_info=True)
+
     # ==================== Queue Notifications ====================
 
     async def on_queue_job_added(

+ 8 - 1
backend/app/services/print_scheduler.py

@@ -1517,6 +1517,7 @@ class PrintScheduler:
                     printer_id=item.printer_id,
                     source_file=file_path,
                     original_filename=filename,
+                    created_by_id=item.created_by_id,
                 )
                 if archive:
                     item.archive_id = archive.id
@@ -1650,7 +1651,13 @@ class PrintScheduler:
         if archive:
             from backend.app.main import register_expected_print
 
-            register_expected_print(item.printer_id, remote_filename, archive.id, ams_mapping=ams_mapping)
+            register_expected_print(
+                item.printer_id,
+                remote_filename,
+                archive.id,
+                ams_mapping=ams_mapping,
+                created_by_id=item.created_by_id,
+            )
 
         # IMPORTANT: Set status to "printing" BEFORE sending the print command.
         # This prevents phantom reprints if the backend crashes/restarts after the

+ 2 - 0
frontend/src/App.tsx

@@ -19,6 +19,7 @@ import InventoryPage from './pages/InventoryPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { LoginPage } from './pages/LoginPage';
 import { SetupPage } from './pages/SetupPage';
+import { NotificationsPage } from './pages/NotificationsPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -146,6 +147,7 @@ function App() {
                   <Route path="users" element={<Navigate to="/settings?tab=users" replace />} />
                   <Route path="groups" element={<Navigate to="/settings?tab=users" replace />} />
                   <Route path="system" element={<SystemInfoPage />} />
+                  <Route path="notifications" element={<NotificationsPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />
                 </Route>
               </Routes>

+ 20 - 1
frontend/src/api/client.ts

@@ -860,6 +860,8 @@ export interface AppSettings {
   bed_cooled_threshold: number;
   // Inventory low stock threshold
   low_stock_threshold: number;
+  // User email notifications toggle
+  user_notifications_enabled: boolean;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -2051,7 +2053,7 @@ export type Permission =
   | 'camera:view'
   | 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete'
   | 'kprofiles:read' | 'kprofiles:create' | 'kprofiles:update' | 'kprofiles:delete'
-  | 'notifications:read' | 'notifications:create' | 'notifications:update' | 'notifications:delete'
+  | 'notifications:read' | 'notifications:create' | 'notifications:update' | 'notifications:delete' | 'notifications:user_email'
   | 'notification_templates:read' | 'notification_templates:update'
   | 'external_links:read' | 'external_links:create' | 'external_links:update' | 'external_links:delete'
   | 'discovery:scan'
@@ -2115,6 +2117,14 @@ export interface PermissionsListResponse {
   all_permissions: Permission[];
 }
 
+// User email notification preferences
+export interface UserEmailPreferences {
+  notify_print_start: boolean;
+  notify_print_complete: boolean;
+  notify_print_failed: boolean;
+  notify_print_stopped: boolean;
+}
+
 // Auth types
 export interface LoginRequest {
   username: string;
@@ -2294,6 +2304,15 @@ export const api = {
       body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
     }),
 
+  // User Email Notifications
+  getUserEmailPreferences: () =>
+    request<UserEmailPreferences>('/user-notifications/preferences'),
+  updateUserEmailPreferences: (data: UserEmailPreferences) =>
+    request<UserEmailPreferences>('/user-notifications/preferences', {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+
   // Groups
   getPermissions: () => request<PermissionsListResponse>('/groups/permissions'),
   getGroups: () => request<Group[]>('/groups/'),

+ 19 - 3
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -25,6 +25,7 @@ interface NavItem {
 }
 
 export const defaultNavItems: NavItem[] = [
+  // Primary workflow items
   { id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' },
   { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
   { id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' },
@@ -34,6 +35,8 @@ export const defaultNavItems: NavItem[] = [
   { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
   { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
   { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
+  // User-account features: kept adjacent to Settings intentionally
+  { id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' },
   { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
 ];
 
@@ -114,6 +117,14 @@ export function Layout() {
     staleTime: 5 * 60 * 1000, // 5 minutes
   });
 
+  // Check advanced auth status for conditional nav items
+  const { data: advancedAuthStatus } = useQuery({
+    queryKey: ['advancedAuthStatus'],
+    queryFn: api.getAdvancedAuthStatus,
+    staleTime: 5 * 60 * 1000, // 5 minutes
+    enabled: authEnabled,
+  });
+
   const { data: updateCheck } = useQuery({
     queryKey: ['updateCheck'],
     queryFn: api.checkForUpdates,
@@ -234,10 +245,15 @@ export function Layout() {
       inventory: 'inventory:read',
       files: 'library:read',
       settings: 'settings:read',
+      notifications: 'notifications:user_email',
     };
 
-    const isHidden = (id: string) =>
-      authEnabled && id in navPermissions && !hasPermission(navPermissions[id]);
+    const isHidden = (id: string) => {
+      if (authEnabled && id in navPermissions && !hasPermission(navPermissions[id])) return true;
+      // notifications nav item also requires advanced auth to be enabled and user_notifications_enabled setting
+      if (id === 'notifications' && (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || (settings?.user_notifications_enabled === false))) return true;
+      return false;
+    };
 
     // Add items in stored order
     for (const id of sidebarOrder) {

+ 24 - 0
frontend/src/i18n/locales/de.ts

@@ -10,6 +10,7 @@ export default {
     projects: 'Projekte',
     inventory: 'Filament',
     files: 'Dateimanager',
+    notifications: 'Benachrichtigungen',
     settings: 'Einstellungen',
     system: 'System',
     collapseSidebar: 'Seitenleiste einklappen',
@@ -1286,6 +1287,9 @@ export default {
     notificationLanguageDescription: 'Sprache für Push-Benachrichtigungen',
     bedCooledThreshold: 'Bett-Abkühlung Schwellenwert',
     bedCooledThresholdDescription: 'Temperatur, unter der das Bett nach einem Druck als abgekühlt gilt',
+    userNotificationsEnabled: 'Benutzerbenachrichtigungen',
+    userNotificationsEnabledDescription: 'Aktiviert das Benutzerbenachrichtigungsmenü und E-Mail-Benachrichtigungen für Druckereignisse. Erfordert Erweiterte Authentifizierung.',
+    userNotificationsDisabledHint: 'Erweiterte Authentifizierung aktivieren, um Benutzerbenachrichtigungen zu verwenden.',
     notificationProviders: 'Benachrichtigungsanbieter',
     addProvider: 'Anbieter hinzufügen',
     editProvider: 'Anbieter bearbeiten',
@@ -3927,6 +3931,26 @@ export default {
       maintenance_due: 'Wartung fällig',
       test: 'Test',
     },
+    // User email notification preferences
+    userEmail: {
+      title: 'Benachrichtigungen',
+      emailNotifications: 'E-Mail-Benachrichtigungen',
+      emailNotificationsDesc: 'Erhalten Sie E-Mail-Benachrichtigungen für Ihre eigenen Druckaufträge. E-Mails werden über die in der erweiterten Authentifizierung konfigurierten SMTP-Einstellungen gesendet.',
+      sendingTo: 'Benachrichtigungen werden gesendet an',
+      noEmailWarning: 'Ihr Konto hat keine E-Mail-Adresse. Wenden Sie sich an einen Administrator, um eine hinzuzufügen.',
+      printJobNotifications: 'Druckauftrags-Benachrichtigungen',
+      printJobNotificationsDesc: 'Wählen Sie aus, welche Ereignisse E-Mail-Benachrichtigungen für von Ihnen gesendete Druckaufträge auslösen.',
+      printJobStarts: 'Druckauftrag startet',
+      printJobStartsDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag beginnt.',
+      printJobFinishes: 'Druckauftrag fertig',
+      printJobFinishesDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag erfolgreich abgeschlossen wurde.',
+      printErrors: 'Druckfehler',
+      printErrorsDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag fehlschlägt oder auf einen Fehler stößt.',
+      printJobStops: 'Druckauftrag gestoppt',
+      printJobStopsDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag abgebrochen oder gestoppt wird.',
+      saveSuccess: 'Benachrichtigungseinstellungen gespeichert.',
+      saveError: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.',
+    },
   },
 
   // Rich Text Editor

+ 24 - 0
frontend/src/i18n/locales/en.ts

@@ -10,6 +10,7 @@ export default {
     projects: 'Projects',
     inventory: 'Filament',
     files: 'File Manager',
+    notifications: 'Notifications',
     settings: 'Settings',
     system: 'System',
     collapseSidebar: 'Collapse sidebar',
@@ -1286,6 +1287,9 @@ export default {
     notificationLanguageDescription: 'Language for push notifications',
     bedCooledThreshold: 'Bed Cooled Threshold',
     bedCooledThresholdDescription: 'Temperature below which the bed is considered cooled after a print',
+    userNotificationsEnabled: 'User Notifications',
+    userNotificationsEnabledDescription: 'Enable the user notifications menu and email notifications for print job events. Requires Advanced Authentication.',
+    userNotificationsDisabledHint: 'Enable Advanced Authentication to use user notifications.',
     notificationProviders: 'Notification Providers',
     addProvider: 'Add Provider',
     editProvider: 'Edit Provider',
@@ -3932,6 +3936,26 @@ export default {
       maintenance_due: 'Maintenance Due',
       test: 'Test',
     },
+    // User email notification preferences
+    userEmail: {
+      title: 'Notifications',
+      emailNotifications: 'Email Notifications',
+      emailNotificationsDesc: 'Receive email notifications for your own print jobs. Emails are sent using the system SMTP settings configured in Advanced Authentication.',
+      sendingTo: 'Notifications will be sent to',
+      noEmailWarning: 'Your account does not have an email address. Contact an administrator to add one.',
+      printJobNotifications: 'Print Job Notifications',
+      printJobNotificationsDesc: 'Choose which events trigger email notifications for print jobs you submit.',
+      printJobStarts: 'Print Job Starts',
+      printJobStartsDesc: 'Get notified when your print job begins.',
+      printJobFinishes: 'Print Job Finishes',
+      printJobFinishesDesc: 'Get notified when your print job completes successfully.',
+      printErrors: 'Print Errors',
+      printErrorsDesc: 'Get notified when your print job fails or encounters an error.',
+      printJobStops: 'Print Job Stops',
+      printJobStopsDesc: 'Get notified when your print job is cancelled or stopped.',
+      saveSuccess: 'Notification preferences saved.',
+      saveError: 'Failed to save notification preferences.',
+    },
   },
 
   // Rich Text Editor

+ 24 - 0
frontend/src/i18n/locales/fr.ts

@@ -10,6 +10,7 @@ export default {
     projects: 'Projets',
     inventory: 'Filament',
     files: 'Gestionnaire de fichiers',
+    notifications: 'Notifications',
     settings: 'Paramètres',
     system: 'Système',
     collapseSidebar: 'Réduire la barre latérale',
@@ -1286,6 +1287,9 @@ export default {
     notificationLanguageDescription: 'Langue pour les notifications push',
     bedCooledThreshold: 'Seuil de refroidissement du plateau',
     bedCooledThresholdDescription: 'Température en dessous de laquelle le plateau est considéré comme refroidi',
+    userNotificationsEnabled: 'Notifications utilisateur',
+    userNotificationsEnabledDescription: "Active le menu de notifications utilisateur et les notifications par e-mail pour les événements d'impression. Nécessite l'authentification avancée.",
+    userNotificationsDisabledHint: "Activez l'authentification avancée pour utiliser les notifications utilisateur.",
     notificationProviders: 'Fournisseurs de notifications',
     addProvider: 'Ajouter un fournisseur',
     editProvider: 'Modifier le fournisseur',
@@ -3919,6 +3923,26 @@ export default {
       maintenance_due: 'Maintenance requise',
       test: 'Test',
     },
+    // User email notification preferences
+    userEmail: {
+      title: 'Notifications',
+      emailNotifications: 'Notifications par e-mail',
+      emailNotificationsDesc: "Recevez des notifications par e-mail pour vos propres travaux d'impression. Les e-mails sont envoyés via les paramètres SMTP configurés dans l'authentification avancée.",
+      sendingTo: 'Les notifications seront envoyées à',
+      noEmailWarning: "Votre compte n'a pas d'adresse e-mail. Contactez un administrateur pour en ajouter une.",
+      printJobNotifications: "Notifications de travaux d'impression",
+      printJobNotificationsDesc: "Choisissez quels événements déclenchent des notifications par e-mail pour les travaux d'impression que vous soumettez.",
+      printJobStarts: "Démarrage du travail d'impression",
+      printJobStartsDesc: "Être notifié quand votre travail d'impression commence.",
+      printJobFinishes: "Fin du travail d'impression",
+      printJobFinishesDesc: "Être notifié quand votre travail d'impression se termine avec succès.",
+      printErrors: "Erreurs d'impression",
+      printErrorsDesc: "Être notifié quand votre travail d'impression échoue ou rencontre une erreur.",
+      printJobStops: "Travail d'impression arrêté",
+      printJobStopsDesc: "Être notifié quand votre travail d'impression est annulé ou arrêté.",
+      saveSuccess: 'Préférences de notification sauvegardées.',
+      saveError: 'Impossible de sauvegarder les préférences de notification.',
+    },
   },
 
   // Rich Text Editor

+ 24 - 0
frontend/src/i18n/locales/it.ts

@@ -10,6 +10,7 @@ export default {
     projects: 'Progetti',
     inventory: 'Filamento',
     files: 'File',
+    notifications: 'Notifiche',
     settings: 'Impostazioni',
     system: 'Sistema',
     collapseSidebar: 'Comprimi barra laterale',
@@ -1286,6 +1287,9 @@ export default {
     notificationLanguageDescription: 'Lingua per notifiche push',
     bedCooledThreshold: 'Soglia raffreddamento piatto',
     bedCooledThresholdDescription: 'Temperatura sotto la quale il piatto è considerato raffreddato dopo una stampa',
+    userNotificationsEnabled: 'Notifiche utente',
+    userNotificationsEnabledDescription: "Abilita il menu notifiche utente e le notifiche e-mail per gli eventi di stampa. Richiede l'autenticazione avanzata.",
+    userNotificationsDisabledHint: "Abilita l'autenticazione avanzata per usare le notifiche utente.",
     notificationProviders: 'Provider notifiche',
     addProvider: 'Aggiungi provider',
     editProvider: 'Modifica provider',
@@ -3918,6 +3922,26 @@ export default {
       maintenance_due: 'Manutenzione necessaria',
       test: 'Prova',
     },
+    // User email notification preferences
+    userEmail: {
+      title: 'Notifiche',
+      emailNotifications: 'Notifiche via e-mail',
+      emailNotificationsDesc: "Ricevi notifiche via e-mail per i tuoi lavori di stampa. Le e-mail vengono inviate tramite le impostazioni SMTP configurate nell'autenticazione avanzata.",
+      sendingTo: 'Le notifiche verranno inviate a',
+      noEmailWarning: "Il tuo account non ha un indirizzo e-mail. Contatta un amministratore per aggiungerne uno.",
+      printJobNotifications: 'Notifiche lavori di stampa',
+      printJobNotificationsDesc: 'Scegli quali eventi attivano le notifiche e-mail per i lavori di stampa che invii.',
+      printJobStarts: 'Inizio lavoro di stampa',
+      printJobStartsDesc: 'Ricevi una notifica quando il tuo lavoro di stampa inizia.',
+      printJobFinishes: 'Fine lavoro di stampa',
+      printJobFinishesDesc: 'Ricevi una notifica quando il tuo lavoro di stampa si completa correttamente.',
+      printErrors: 'Errori di stampa',
+      printErrorsDesc: 'Ricevi una notifica quando il tuo lavoro di stampa fallisce o incontra un errore.',
+      printJobStops: 'Lavoro di stampa interrotto',
+      printJobStopsDesc: 'Ricevi una notifica quando il tuo lavoro di stampa viene annullato o interrotto.',
+      saveSuccess: 'Preferenze di notifica salvate.',
+      saveError: 'Impossibile salvare le preferenze di notifica.',
+    },
   },
 
   // Rich Text Editor

+ 24 - 0
frontend/src/i18n/locales/ja.ts

@@ -10,6 +10,7 @@ export default {
     projects: 'プロジェクト',
     inventory: 'フィラメント',
     files: 'ファイル管理',
+    notifications: '通知',
     settings: '設定',
     system: 'システム',
     collapseSidebar: 'サイドバーを閉じる',
@@ -1285,6 +1286,9 @@ export default {
     notificationLanguageDescription: 'プッシュ通知の言語',
     bedCooledThreshold: 'ベッド冷却しきい値',
     bedCooledThresholdDescription: '印刷後にベッドが冷却されたと見なす温度',
+    userNotificationsEnabled: 'ユーザー通知',
+    userNotificationsEnabledDescription: 'ユーザー通知メニューと印刷ジョブイベントのメール通知を有効にします。高度な認証が必要です。',
+    userNotificationsDisabledHint: 'ユーザー通知を使用するには高度な認証を有効にしてください。',
     notificationProviders: '通知プロバイダー',
     addProvider: 'プロバイダーを追加',
     editProvider: 'プロバイダーを編集',
@@ -3931,6 +3935,26 @@ export default {
       maintenance_due: 'メンテナンス期限',
       test: 'テスト',
     },
+    // User email notification preferences
+    userEmail: {
+      title: '通知',
+      emailNotifications: 'メール通知',
+      emailNotificationsDesc: '自分の印刷ジョブに対してメール通知を受け取ります。メールは高度な認証で設定されたSMTP設定を使用して送信されます。',
+      sendingTo: '通知の送信先',
+      noEmailWarning: 'アカウントにメールアドレスが設定されていません。管理者に連絡して追加してもらってください。',
+      printJobNotifications: '印刷ジョブ通知',
+      printJobNotificationsDesc: '送信した印刷ジョブのどのイベントでメール通知を送るかを選択します。',
+      printJobStarts: '印刷ジョブ開始',
+      printJobStartsDesc: '印刷ジョブが開始されたときに通知を受け取る。',
+      printJobFinishes: '印刷ジョブ完了',
+      printJobFinishesDesc: '印刷ジョブが正常に完了したときに通知を受け取る。',
+      printErrors: '印刷エラー',
+      printErrorsDesc: '印刷ジョブが失敗またはエラーが発生したときに通知を受け取る。',
+      printJobStops: '印刷ジョブ停止',
+      printJobStopsDesc: '印刷ジョブがキャンセルまたは停止されたときに通知を受け取る。',
+      saveSuccess: '通知設定を保存しました。',
+      saveError: '通知設定の保存に失敗しました。',
+    },
   },
 
   // Rich Text Editor

+ 24 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -10,6 +10,7 @@ export default {
     projects: 'Projetos',
     inventory: 'Inventário',
     files: 'Gerenciador de Arquivos',
+    notifications: 'Notificações',
     settings: 'Configurações',
     system: 'Sistema',
     collapseSidebar: 'Recolher barra lateral',
@@ -1286,6 +1287,9 @@ export default {
     notificationLanguageDescription: 'Idioma para notificações push',
     bedCooledThreshold: 'Limite de Resfriamento da Cama',
     bedCooledThresholdDescription: 'Temperatura abaixo da qual a cama é considerada resfriada após uma impressão',
+    userNotificationsEnabled: 'Notificações do Usuário',
+    userNotificationsEnabledDescription: 'Ativa o menu de notificações do usuário e notificações por e-mail para eventos de impressão. Requer Autenticação Avançada.',
+    userNotificationsDisabledHint: 'Ative a Autenticação Avançada para usar as notificações do usuário.',
     notificationProviders: 'Provedores de Notificação',
     addProvider: 'Adicionar Provedor',
     editProvider: 'Editar Provedor',
@@ -3918,6 +3922,26 @@ export default {
       maintenance_due: 'Manutenção Necessária',
       test: 'Teste',
     },
+    // User email notification preferences
+    userEmail: {
+      title: 'Notificações',
+      emailNotifications: 'Notificações por E-mail',
+      emailNotificationsDesc: 'Receba notificações por e-mail para seus próprios trabalhos de impressão. Os e-mails são enviados usando as configurações SMTP definidas na Autenticação Avançada.',
+      sendingTo: 'As notificações serão enviadas para',
+      noEmailWarning: 'Sua conta não tem um endereço de e-mail. Entre em contato com um administrador para adicionar um.',
+      printJobNotifications: 'Notificações de Trabalhos de Impressão',
+      printJobNotificationsDesc: 'Escolha quais eventos acionam notificações por e-mail para os trabalhos de impressão que você envia.',
+      printJobStarts: 'Início do Trabalho de Impressão',
+      printJobStartsDesc: 'Ser notificado quando seu trabalho de impressão começar.',
+      printJobFinishes: 'Conclusão do Trabalho de Impressão',
+      printJobFinishesDesc: 'Ser notificado quando seu trabalho de impressão concluir com sucesso.',
+      printErrors: 'Erros de Impressão',
+      printErrorsDesc: 'Ser notificado quando seu trabalho de impressão falhar ou encontrar um erro.',
+      printJobStops: 'Trabalho de Impressão Parado',
+      printJobStopsDesc: 'Ser notificado quando seu trabalho de impressão for cancelado ou parado.',
+      saveSuccess: 'Preferências de notificação salvas.',
+      saveError: 'Falha ao salvar preferências de notificação.',
+    },
   },
 
   // Rich Text Editor

+ 23 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -10,6 +10,7 @@ export default {
     projects: '项目',
     inventory: '耗材',
     files: '文件管理器',
+    notifications: '通知',
     settings: '设置',
     system: '系统',
     collapseSidebar: '收起侧边栏',
@@ -1286,6 +1287,9 @@ export default {
     notificationLanguageDescription: '推送通知的语言',
     bedCooledThreshold: '热床冷却阈值',
     bedCooledThresholdDescription: '打印后热床被视为已冷却的温度',
+    userNotificationsEnabled: '用户通知',
+    userNotificationsEnabledDescription: '启用用户通知菜单和打印任务事件的邮件通知。需要高级身份验证。',
+    userNotificationsDisabledHint: '请启用高级身份验证以使用用户通知。',
     notificationProviders: '通知提供商',
     addProvider: '添加提供商',
     editProvider: '编辑提供商',
@@ -3918,6 +3922,25 @@ export default {
       maintenance_due: '需要维护',
       test: '测试',
     },
+    userEmail: {
+      title: '通知',
+      emailNotifications: '邮件通知',
+      emailNotificationsDesc: '接收您自己打印任务的邮件通知。邮件将通过高级身份验证中配置的 SMTP 设置发送。',
+      sendingTo: '通知将发送至',
+      noEmailWarning: '您的账户没有邮件地址。请联系管理员添加。',
+      printJobNotifications: '打印任务通知',
+      printJobNotificationsDesc: '选择哪些事件会触发您提交的打印任务的邮件通知。',
+      printJobStarts: '打印任务开始',
+      printJobStartsDesc: '当您的打印任务开始时收到通知。',
+      printJobFinishes: '打印任务完成',
+      printJobFinishesDesc: '当您的打印任务成功完成时收到通知。',
+      printErrors: '打印错误',
+      printErrorsDesc: '当您的打印任务失败或遇到错误时收到通知。',
+      printJobStops: '打印任务停止',
+      printJobStopsDesc: '当您的打印任务被取消或停止时收到通知。',
+      saveSuccess: '通知偏好设置已保存。',
+      saveError: '保存通知偏好设置失败。',
+    },
   },
 
   // Rich Text Editor

+ 268 - 0
frontend/src/pages/NotificationsPage.tsx

@@ -0,0 +1,268 @@
+import { useState, useEffect } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useNavigate } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { Bell, CheckCircle2, Loader2, Mail, Save } from 'lucide-react';
+import { api } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import { Button } from '../components/Button';
+import { Card, CardContent, CardHeader } from '../components/Card';
+
+export function NotificationsPage() {
+  const { t } = useTranslation();
+  const { user } = useAuth();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const navigate = useNavigate();
+
+  const [notifyPrintStart, setNotifyPrintStart] = useState(true);
+  const [notifyPrintComplete, setNotifyPrintComplete] = useState(true);
+  const [notifyPrintFailed, setNotifyPrintFailed] = useState(true);
+  const [notifyPrintStopped, setNotifyPrintStopped] = useState(true);
+  const [isDirty, setIsDirty] = useState(false);
+
+  // Check advanced auth status - redirect if disabled
+  const { data: advancedAuthStatus, isLoading: isAdvancedAuthLoading } = useQuery({
+    queryKey: ['advancedAuthStatus'],
+    queryFn: api.getAdvancedAuthStatus,
+    staleTime: 5 * 60 * 1000, // 5 minutes
+  });
+
+  const { data: settings, isLoading: isSettingsLoading } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+    staleTime: 5 * 60 * 1000,
+  });
+  
+  // Fetch current preferences
+  const { data: preferences, isLoading } = useQuery({
+    queryKey: ['user-email-preferences'],
+    queryFn: () => api.getUserEmailPreferences(),
+  });
+
+  // Redirect to settings if Advanced Auth is disabled
+  useEffect(() => {
+    if ((advancedAuthStatus && !advancedAuthStatus.advanced_auth_enabled) || (settings && !settings.user_notifications_enabled)) {
+      navigate('/settings', { replace: true });
+    }
+  }, [advancedAuthStatus, settings, navigate]);
+
+  // Populate form when preferences load
+  useEffect(() => {
+    if (preferences) {
+      setNotifyPrintStart(preferences.notify_print_start);
+      setNotifyPrintComplete(preferences.notify_print_complete);
+      setNotifyPrintFailed(preferences.notify_print_failed);
+      setNotifyPrintStopped(preferences.notify_print_stopped);
+      setIsDirty(false);
+    }
+  }, [preferences]);
+
+  // Save preferences
+  const saveMutation = useMutation({
+    mutationFn: () =>
+      api.updateUserEmailPreferences({
+        notify_print_start: notifyPrintStart,
+        notify_print_complete: notifyPrintComplete,
+        notify_print_failed: notifyPrintFailed,
+        notify_print_stopped: notifyPrintStopped,
+      }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['user-email-preferences'] });
+      setIsDirty(false);
+      showToast(t('notifications.userEmail.saveSuccess'), 'success');
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('notifications.userEmail.saveError'), 'error');
+    },
+  });
+
+  const handleToggle = (
+    setter: React.Dispatch<React.SetStateAction<boolean>>,
+    value: boolean
+  ) => {
+    setter(!value);
+    setIsDirty(true);
+  };
+
+  if (isLoading || isAdvancedAuthLoading || isSettingsLoading) {
+    return (
+      <div className="flex items-center justify-center h-64">
+        <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+      </div>
+    );
+  }
+
+  return (
+    <div className="p-4 md:p-6 max-w-2xl mx-auto">
+      <div className="flex items-center gap-3 mb-6">
+        <Bell className="w-7 h-7 text-bambu-green" />
+        <h1 className="text-2xl font-bold text-white">{t('notifications.userEmail.title')}</h1>
+      </div>
+
+      {/* Info card */}
+      <Card className="mb-6 border-blue-500/30 bg-blue-500/5">
+        <CardContent className="py-4">
+          <div className="flex items-start gap-3">
+            <div className="w-10 h-10 rounded-full flex items-center justify-center bg-blue-500/20 flex-shrink-0">
+              <Mail className="w-5 h-5 text-blue-400" />
+            </div>
+            <div>
+              <h3 className="text-white font-medium">{t('notifications.userEmail.emailNotifications')}</h3>
+              <p className="text-sm text-bambu-gray mt-1">
+                {t('notifications.userEmail.emailNotificationsDesc')}
+              </p>
+              {user?.email ? (
+                <p className="text-sm text-blue-400 mt-2">
+                  {t('notifications.userEmail.sendingTo')}: <strong>{user.email}</strong>
+                </p>
+              ) : (
+                <p className="text-sm text-yellow-400 mt-2">
+                  {t('notifications.userEmail.noEmailWarning')}
+                </p>
+              )}
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+
+      {/* Preferences card */}
+      <Card className="mb-6">
+        <CardHeader>
+          <h2 className="text-lg font-semibold text-white">{t('notifications.userEmail.printJobNotifications')}</h2>
+          <p className="text-sm text-bambu-gray mt-1">{t('notifications.userEmail.printJobNotificationsDesc')}</p>
+        </CardHeader>
+        <CardContent className="space-y-4">
+          {/* Print Job Starts */}
+          <div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
+            <div className="flex items-center gap-3">
+              <div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintStart ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>
+                <CheckCircle2 className={`w-5 h-5 ${notifyPrintStart ? 'text-bambu-green' : 'text-bambu-gray'}`} />
+              </div>
+              <div>
+                <p className="text-white font-medium">{t('notifications.userEmail.printJobStarts')}</p>
+                <p className="text-sm text-bambu-gray">{t('notifications.userEmail.printJobStartsDesc')}</p>
+              </div>
+            </div>
+            <button
+              onClick={() => handleToggle(setNotifyPrintStart, notifyPrintStart)}
+              className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
+                notifyPrintStart ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+              }`}
+              role="switch"
+              aria-checked={notifyPrintStart}
+            >
+              <span
+                className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
+                  notifyPrintStart ? 'translate-x-6' : 'translate-x-1'
+                }`}
+              />
+            </button>
+          </div>
+
+          {/* Print Job Finishes */}
+          <div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
+            <div className="flex items-center gap-3">
+              <div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintComplete ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>
+                <CheckCircle2 className={`w-5 h-5 ${notifyPrintComplete ? 'text-bambu-green' : 'text-bambu-gray'}`} />
+              </div>
+              <div>
+                <p className="text-white font-medium">{t('notifications.userEmail.printJobFinishes')}</p>
+                <p className="text-sm text-bambu-gray">{t('notifications.userEmail.printJobFinishesDesc')}</p>
+              </div>
+            </div>
+            <button
+              onClick={() => handleToggle(setNotifyPrintComplete, notifyPrintComplete)}
+              className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
+                notifyPrintComplete ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+              }`}
+              role="switch"
+              aria-checked={notifyPrintComplete}
+            >
+              <span
+                className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
+                  notifyPrintComplete ? 'translate-x-6' : 'translate-x-1'
+                }`}
+              />
+            </button>
+          </div>
+
+          {/* Print Errors */}
+          <div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
+            <div className="flex items-center gap-3">
+              <div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintFailed ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>
+                <CheckCircle2 className={`w-5 h-5 ${notifyPrintFailed ? 'text-bambu-green' : 'text-bambu-gray'}`} />
+              </div>
+              <div>
+                <p className="text-white font-medium">{t('notifications.userEmail.printErrors')}</p>
+                <p className="text-sm text-bambu-gray">{t('notifications.userEmail.printErrorsDesc')}</p>
+              </div>
+            </div>
+            <button
+              onClick={() => handleToggle(setNotifyPrintFailed, notifyPrintFailed)}
+              className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
+                notifyPrintFailed ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+              }`}
+              role="switch"
+              aria-checked={notifyPrintFailed}
+            >
+              <span
+                className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
+                  notifyPrintFailed ? 'translate-x-6' : 'translate-x-1'
+                }`}
+              />
+            </button>
+          </div>
+
+          {/* Print Job Stops */}
+          <div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
+            <div className="flex items-center gap-3">
+              <div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintStopped ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>
+                <CheckCircle2 className={`w-5 h-5 ${notifyPrintStopped ? 'text-bambu-green' : 'text-bambu-gray'}`} />
+              </div>
+              <div>
+                <p className="text-white font-medium">{t('notifications.userEmail.printJobStops')}</p>
+                <p className="text-sm text-bambu-gray">{t('notifications.userEmail.printJobStopsDesc')}</p>
+              </div>
+            </div>
+            <button
+              onClick={() => handleToggle(setNotifyPrintStopped, notifyPrintStopped)}
+              className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
+                notifyPrintStopped ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+              }`}
+              role="switch"
+              aria-checked={notifyPrintStopped}
+            >
+              <span
+                className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
+                  notifyPrintStopped ? 'translate-x-6' : 'translate-x-1'
+                }`}
+              />
+            </button>
+          </div>
+        </CardContent>
+      </Card>
+
+      {/* Save button */}
+      <div className="flex justify-end">
+        <Button
+          onClick={() => saveMutation.mutate()}
+          disabled={!isDirty || saveMutation.isPending || !user?.email}
+        >
+          {saveMutation.isPending ? (
+            <>
+              <Loader2 className="w-4 h-4 animate-spin" />
+              {t('common.saving')}
+            </>
+          ) : (
+            <>
+              <Save className="w-4 h-4" />
+              {t('common.save')}
+            </>
+          )}
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 30 - 1
frontend/src/pages/SettingsPage.tsx

@@ -523,6 +523,7 @@ export function SettingsPage() {
     const updateData: UserUpdate = {
       username: userFormData.username || undefined,
       password: userFormData.password || undefined,
+      email: userFormData.email || undefined,
       role: userFormData.role,
       group_ids: userFormData.group_ids,
     };
@@ -738,7 +739,8 @@ export function SettingsPage() {
       (settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
       (settings.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
       settings.prometheus_enabled !== localSettings.prometheus_enabled ||
-      settings.prometheus_token !== localSettings.prometheus_token;
+      settings.prometheus_token !== localSettings.prometheus_token ||
+      (settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true);
 
     if (!hasChanges) {
       return;
@@ -810,6 +812,7 @@ export function SettingsPage() {
         preferred_slicer: localSettings.preferred_slicer,
         prometheus_enabled: localSettings.prometheus_enabled,
         prometheus_token: localSettings.prometheus_token,
+        user_notifications_enabled: localSettings.user_notifications_enabled,
       };
       updateMutation.mutate(settingsToSave);
     }, 500);
@@ -2784,6 +2787,32 @@ export function SettingsPage() {
               </CardContent>
             </Card>
 
+            {/* User Notifications Toggle */}
+            <Card className="mb-4">
+              <CardContent className="py-3">
+                <div className={`flex items-center justify-between ${!advancedAuthStatus?.advanced_auth_enabled ? 'opacity-50' : ''}`}>
+                  <div>
+                    <p className="text-white text-sm font-medium">{t('settings.userNotificationsEnabled')}</p>
+                    <p className="text-xs text-bambu-gray">
+                      {!advancedAuthStatus?.advanced_auth_enabled
+                        ? t('settings.userNotificationsDisabledHint')
+                        : t('settings.userNotificationsEnabledDescription')}
+                    </p>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer">
+                    <input
+                      type="checkbox"
+                      className="sr-only peer"
+                      checked={localSettings.user_notifications_enabled ?? true}
+                      disabled={!advancedAuthStatus?.advanced_auth_enabled}
+                      onChange={(e) => updateSetting('user_notifications_enabled', e.target.checked)}
+                    />
+                    <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green peer-disabled:cursor-not-allowed"></div>
+                  </label>
+                </div>
+              </CardContent>
+            </Card>
+
             {/* Test All Results */}
             {testAllResult && (
               <Card className="mb-4">