diff --git a/backend/app/api/routes/notification_templates.py b/backend/app/api/routes/notification_templates.py
index 55371e96f..1a8a5a63f 100644
--- a/backend/app/api/routes/notification_templates.py
+++ b/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",
}
diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py
index 0154b6583..f1781d96d 100644
--- a/backend/app/api/routes/print_queue.py
+++ b/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)
diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py
index 556d8acb7..631911df9 100644
--- a/backend/app/api/routes/settings.py
+++ b/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",
diff --git a/backend/app/api/routes/user_notifications.py b/backend/app/api/routes/user_notifications.py
new file mode 100644
index 000000000..ec34ae1cf
--- /dev/null
+++ b/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
diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py
index 611486f4e..fa3daa8fb 100644
--- a/backend/app/api/routes/users.py
+++ b/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)
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
index 8478d118d..1a8d67421 100644
--- a/backend/app/core/database.py
+++ b/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."""
diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py
index 91183ce8d..a03d31bc7 100644
--- a/backend/app/core/permissions.py
+++ b/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
diff --git a/backend/app/main.py b/backend/app/main.py
index 15a63f1ee..a9ed449f0 100644
--- a/backend/app/main.py
+++ b/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)
diff --git a/backend/app/models/notification_template.py b/backend/app/models/notification_template.py
index 4f6fc1584..40c8f920f 100644
--- a/backend/app/models/notification_template.py
+++ b/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}",
+ },
]
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index 28d3c2f48..d720a2bf8 100644
--- a/backend/app/models/user.py
+++ b/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.
diff --git a/backend/app/models/user_email_pref.py b/backend/app/models/user_email_pref.py
new file mode 100644
index 000000000..58aa74e60
--- /dev/null
+++ b/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")
diff --git a/backend/app/schemas/notification_template.py b/backend/app/schemas/notification_template.py
index a9494838c..2e6f3bb8f 100644
--- a/backend/app/schemas/notification_template.py
+++ b/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",
+ },
}
diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py
index e36eb706c..56e08dba9 100644
--- a/backend/app/schemas/settings.py
+++ b/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
diff --git a/backend/app/schemas/user_notifications.py b/backend/app/schemas/user_notifications.py
new file mode 100644
index 000000000..7a8b8cab1
--- /dev/null
+++ b/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
diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py
index 455d1f10e..94f0db748 100644
--- a/backend/app/services/email_service.py
+++ b/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", "
\n")
+ html_body = f"""
+
+
+ {t('notifications.userEmail.emailNotificationsDesc')} +
+ {user?.email ? ( ++ {t('notifications.userEmail.sendingTo')}: {user.email} +
+ ) : ( ++ {t('notifications.userEmail.noEmailWarning')} +
+ )} +{t('notifications.userEmail.printJobNotificationsDesc')}
+{t('notifications.userEmail.printJobStarts')}
+{t('notifications.userEmail.printJobStartsDesc')}
+{t('notifications.userEmail.printJobFinishes')}
+{t('notifications.userEmail.printJobFinishesDesc')}
+{t('notifications.userEmail.printErrors')}
+{t('notifications.userEmail.printErrorsDesc')}
+{t('notifications.userEmail.printJobStops')}
+{t('notifications.userEmail.printJobStopsDesc')}
+{t('settings.userNotificationsEnabled')}
++ {!advancedAuthStatus?.advanced_auth_enabled + ? t('settings.userNotificationsDisabledHint') + : t('settings.userNotificationsEnabledDescription')} +
+