diff --git a/i18n/en.json b/i18n/en.json
index eafb3415d5..8404d6d1d0 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -857,6 +857,7 @@
     "failed_to_remove_product_key": "Failed to remove product key",
     "failed_to_stack_assets": "Failed to stack assets",
     "failed_to_unstack_assets": "Failed to un-stack assets",
+    "failed_to_update_notification_status": "Failed to update notification status",
     "import_path_already_exists": "This import path already exists.",
     "incorrect_email_or_password": "Incorrect email or password",
     "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
@@ -1199,6 +1200,9 @@
   "map_settings_only_show_favorites": "Show Favorite Only",
   "map_settings_theme_settings": "Map Theme",
   "map_zoom_to_see_photos": "Zoom out to see photos",
+  "mark_as_read": "Mark as read",
+  "mark_all_as_read": "Mark all as read",
+  "marked_all_as_read": "Marked all as read",
   "matches": "Matches",
   "media_type": "Media type",
   "memories": "Memories",
@@ -1260,6 +1264,7 @@
   "no_places": "No places",
   "no_results": "No results",
   "no_results_description": "Try a synonym or more general keyword",
+  "no_notifications": "No notifications",
   "no_shared_albums_message": "Create an album to share photos and videos with people in your network",
   "not_in_any_album": "Not in any album",
   "not_selected": "Not selected",
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 5a7a42cce5..b8ea4b924c 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -145,8 +145,15 @@ Class | Method | HTTP request | Description
 *MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets | 
 *MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories | 
 *MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} | 
-*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /notifications/admin/templates/{name} | 
-*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /notifications/admin/test-email | 
+*NotificationsApi* | [**deleteNotification**](doc//NotificationsApi.md#deletenotification) | **DELETE** /notifications/{id} | 
+*NotificationsApi* | [**deleteNotifications**](doc//NotificationsApi.md#deletenotifications) | **DELETE** /notifications | 
+*NotificationsApi* | [**getNotification**](doc//NotificationsApi.md#getnotification) | **GET** /notifications/{id} | 
+*NotificationsApi* | [**getNotifications**](doc//NotificationsApi.md#getnotifications) | **GET** /notifications | 
+*NotificationsApi* | [**updateNotification**](doc//NotificationsApi.md#updatenotification) | **PUT** /notifications/{id} | 
+*NotificationsApi* | [**updateNotifications**](doc//NotificationsApi.md#updatenotifications) | **PUT** /notifications | 
+*NotificationsAdminApi* | [**createNotification**](doc//NotificationsAdminApi.md#createnotification) | **POST** /admin/notifications | 
+*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /admin/notifications/templates/{name} | 
+*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /admin/notifications/test-email | 
 *OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback | 
 *OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link | 
 *OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect | 
@@ -360,6 +367,13 @@ Class | Method | HTTP request | Description
  - [MemoryUpdateDto](doc//MemoryUpdateDto.md)
  - [MergePersonDto](doc//MergePersonDto.md)
  - [MetadataSearchDto](doc//MetadataSearchDto.md)
+ - [NotificationCreateDto](doc//NotificationCreateDto.md)
+ - [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md)
+ - [NotificationDto](doc//NotificationDto.md)
+ - [NotificationLevel](doc//NotificationLevel.md)
+ - [NotificationType](doc//NotificationType.md)
+ - [NotificationUpdateAllDto](doc//NotificationUpdateAllDto.md)
+ - [NotificationUpdateDto](doc//NotificationUpdateDto.md)
  - [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md)
  - [OAuthCallbackDto](doc//OAuthCallbackDto.md)
  - [OAuthConfigDto](doc//OAuthConfigDto.md)
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index d08f9fda38..e845099bd2 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -44,6 +44,7 @@ part 'api/jobs_api.dart';
 part 'api/libraries_api.dart';
 part 'api/map_api.dart';
 part 'api/memories_api.dart';
+part 'api/notifications_api.dart';
 part 'api/notifications_admin_api.dart';
 part 'api/o_auth_api.dart';
 part 'api/partners_api.dart';
@@ -167,6 +168,13 @@ part 'model/memory_type.dart';
 part 'model/memory_update_dto.dart';
 part 'model/merge_person_dto.dart';
 part 'model/metadata_search_dto.dart';
+part 'model/notification_create_dto.dart';
+part 'model/notification_delete_all_dto.dart';
+part 'model/notification_dto.dart';
+part 'model/notification_level.dart';
+part 'model/notification_type.dart';
+part 'model/notification_update_all_dto.dart';
+part 'model/notification_update_dto.dart';
 part 'model/o_auth_authorize_response_dto.dart';
 part 'model/o_auth_callback_dto.dart';
 part 'model/o_auth_config_dto.dart';
diff --git a/mobile/openapi/lib/api/notifications_admin_api.dart b/mobile/openapi/lib/api/notifications_admin_api.dart
index c58bf8978d..409683a950 100644
--- a/mobile/openapi/lib/api/notifications_admin_api.dart
+++ b/mobile/openapi/lib/api/notifications_admin_api.dart
@@ -16,7 +16,54 @@ class NotificationsAdminApi {
 
   final ApiClient apiClient;
 
-  /// Performs an HTTP 'POST /notifications/admin/templates/{name}' operation and returns the [Response].
+  /// Performs an HTTP 'POST /admin/notifications' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [NotificationCreateDto] notificationCreateDto (required):
+  Future<Response> createNotificationWithHttpInfo(NotificationCreateDto notificationCreateDto,) async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/admin/notifications';
+
+    // ignore: prefer_final_locals
+    Object? postBody = notificationCreateDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [NotificationCreateDto] notificationCreateDto (required):
+  Future<NotificationDto?> createNotification(NotificationCreateDto notificationCreateDto,) async {
+    final response = await createNotificationWithHttpInfo(notificationCreateDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto;
+    
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'POST /admin/notifications/templates/{name}' operation and returns the [Response].
   /// Parameters:
   ///
   /// * [String] name (required):
@@ -24,7 +71,7 @@ class NotificationsAdminApi {
   /// * [TemplateDto] templateDto (required):
   Future<Response> getNotificationTemplateAdminWithHttpInfo(String name, TemplateDto templateDto,) async {
     // ignore: prefer_const_declarations
-    final apiPath = r'/notifications/admin/templates/{name}'
+    final apiPath = r'/admin/notifications/templates/{name}'
       .replaceAll('{name}', name);
 
     // ignore: prefer_final_locals
@@ -68,13 +115,13 @@ class NotificationsAdminApi {
     return null;
   }
 
-  /// Performs an HTTP 'POST /notifications/admin/test-email' operation and returns the [Response].
+  /// Performs an HTTP 'POST /admin/notifications/test-email' operation and returns the [Response].
   /// Parameters:
   ///
   /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required):
   Future<Response> sendTestEmailAdminWithHttpInfo(SystemConfigSmtpDto systemConfigSmtpDto,) async {
     // ignore: prefer_const_declarations
-    final apiPath = r'/notifications/admin/test-email';
+    final apiPath = r'/admin/notifications/test-email';
 
     // ignore: prefer_final_locals
     Object? postBody = systemConfigSmtpDto;
diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart
new file mode 100644
index 0000000000..501cc70a29
--- /dev/null
+++ b/mobile/openapi/lib/api/notifications_api.dart
@@ -0,0 +1,311 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+
+class NotificationsApi {
+  NotificationsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
+
+  final ApiClient apiClient;
+
+  /// Performs an HTTP 'DELETE /notifications/{id}' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<Response> deleteNotificationWithHttpInfo(String id,) async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/notifications/{id}'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'DELETE',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<void> deleteNotification(String id,) async {
+    final response = await deleteNotificationWithHttpInfo(id,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
+  /// Performs an HTTP 'DELETE /notifications' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [NotificationDeleteAllDto] notificationDeleteAllDto (required):
+  Future<Response> deleteNotificationsWithHttpInfo(NotificationDeleteAllDto notificationDeleteAllDto,) async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/notifications';
+
+    // ignore: prefer_final_locals
+    Object? postBody = notificationDeleteAllDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'DELETE',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [NotificationDeleteAllDto] notificationDeleteAllDto (required):
+  Future<void> deleteNotifications(NotificationDeleteAllDto notificationDeleteAllDto,) async {
+    final response = await deleteNotificationsWithHttpInfo(notificationDeleteAllDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
+  /// Performs an HTTP 'GET /notifications/{id}' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<Response> getNotificationWithHttpInfo(String id,) async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/notifications/{id}'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<NotificationDto?> getNotification(String id,) async {
+    final response = await getNotificationWithHttpInfo(id,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto;
+    
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'GET /notifications' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id:
+  ///
+  /// * [NotificationLevel] level:
+  ///
+  /// * [NotificationType] type:
+  ///
+  /// * [bool] unread:
+  Future<Response> getNotificationsWithHttpInfo({ String? id, NotificationLevel? level, NotificationType? type, bool? unread, }) async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/notifications';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    if (id != null) {
+      queryParams.addAll(_queryParams('', 'id', id));
+    }
+    if (level != null) {
+      queryParams.addAll(_queryParams('', 'level', level));
+    }
+    if (type != null) {
+      queryParams.addAll(_queryParams('', 'type', type));
+    }
+    if (unread != null) {
+      queryParams.addAll(_queryParams('', 'unread', unread));
+    }
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id:
+  ///
+  /// * [NotificationLevel] level:
+  ///
+  /// * [NotificationType] type:
+  ///
+  /// * [bool] unread:
+  Future<List<NotificationDto>?> getNotifications({ String? id, NotificationLevel? level, NotificationType? type, bool? unread, }) async {
+    final response = await getNotificationsWithHttpInfo( id: id, level: level, type: type, unread: unread, );
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      final responseBody = await _decodeBodyBytes(response);
+      return (await apiClient.deserializeAsync(responseBody, 'List<NotificationDto>') as List)
+        .cast<NotificationDto>()
+        .toList(growable: false);
+
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'PUT /notifications/{id}' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [NotificationUpdateDto] notificationUpdateDto (required):
+  Future<Response> updateNotificationWithHttpInfo(String id, NotificationUpdateDto notificationUpdateDto,) async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/notifications/{id}'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody = notificationUpdateDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'PUT',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [NotificationUpdateDto] notificationUpdateDto (required):
+  Future<NotificationDto?> updateNotification(String id, NotificationUpdateDto notificationUpdateDto,) async {
+    final response = await updateNotificationWithHttpInfo(id, notificationUpdateDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto;
+    
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'PUT /notifications' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [NotificationUpdateAllDto] notificationUpdateAllDto (required):
+  Future<Response> updateNotificationsWithHttpInfo(NotificationUpdateAllDto notificationUpdateAllDto,) async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/notifications';
+
+    // ignore: prefer_final_locals
+    Object? postBody = notificationUpdateAllDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'PUT',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [NotificationUpdateAllDto] notificationUpdateAllDto (required):
+  Future<void> updateNotifications(NotificationUpdateAllDto notificationUpdateAllDto,) async {
+    final response = await updateNotificationsWithHttpInfo(notificationUpdateAllDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+}
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 0d8e4c6ba9..7586cc1ae2 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -390,6 +390,20 @@ class ApiClient {
           return MergePersonDto.fromJson(value);
         case 'MetadataSearchDto':
           return MetadataSearchDto.fromJson(value);
+        case 'NotificationCreateDto':
+          return NotificationCreateDto.fromJson(value);
+        case 'NotificationDeleteAllDto':
+          return NotificationDeleteAllDto.fromJson(value);
+        case 'NotificationDto':
+          return NotificationDto.fromJson(value);
+        case 'NotificationLevel':
+          return NotificationLevelTypeTransformer().decode(value);
+        case 'NotificationType':
+          return NotificationTypeTypeTransformer().decode(value);
+        case 'NotificationUpdateAllDto':
+          return NotificationUpdateAllDto.fromJson(value);
+        case 'NotificationUpdateDto':
+          return NotificationUpdateDto.fromJson(value);
         case 'OAuthAuthorizeResponseDto':
           return OAuthAuthorizeResponseDto.fromJson(value);
         case 'OAuthCallbackDto':
diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart
index 1ebf8314ad..cc517d48ab 100644
--- a/mobile/openapi/lib/api_helper.dart
+++ b/mobile/openapi/lib/api_helper.dart
@@ -100,6 +100,12 @@ String parameterToString(dynamic value) {
   if (value is MemoryType) {
     return MemoryTypeTypeTransformer().encode(value).toString();
   }
+  if (value is NotificationLevel) {
+    return NotificationLevelTypeTransformer().encode(value).toString();
+  }
+  if (value is NotificationType) {
+    return NotificationTypeTypeTransformer().encode(value).toString();
+  }
   if (value is PartnerDirection) {
     return PartnerDirectionTypeTransformer().encode(value).toString();
   }
diff --git a/mobile/openapi/lib/model/notification_create_dto.dart b/mobile/openapi/lib/model/notification_create_dto.dart
new file mode 100644
index 0000000000..07985353b2
--- /dev/null
+++ b/mobile/openapi/lib/model/notification_create_dto.dart
@@ -0,0 +1,180 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class NotificationCreateDto {
+  /// Returns a new [NotificationCreateDto] instance.
+  NotificationCreateDto({
+    this.data,
+    this.description,
+    this.level,
+    this.readAt,
+    required this.title,
+    this.type,
+    required this.userId,
+  });
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  Object? data;
+
+  String? description;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  NotificationLevel? level;
+
+  DateTime? readAt;
+
+  String title;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  NotificationType? type;
+
+  String userId;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is NotificationCreateDto &&
+    other.data == data &&
+    other.description == description &&
+    other.level == level &&
+    other.readAt == readAt &&
+    other.title == title &&
+    other.type == type &&
+    other.userId == userId;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (data == null ? 0 : data!.hashCode) +
+    (description == null ? 0 : description!.hashCode) +
+    (level == null ? 0 : level!.hashCode) +
+    (readAt == null ? 0 : readAt!.hashCode) +
+    (title.hashCode) +
+    (type == null ? 0 : type!.hashCode) +
+    (userId.hashCode);
+
+  @override
+  String toString() => 'NotificationCreateDto[data=$data, description=$description, level=$level, readAt=$readAt, title=$title, type=$type, userId=$userId]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+    if (this.data != null) {
+      json[r'data'] = this.data;
+    } else {
+    //  json[r'data'] = null;
+    }
+    if (this.description != null) {
+      json[r'description'] = this.description;
+    } else {
+    //  json[r'description'] = null;
+    }
+    if (this.level != null) {
+      json[r'level'] = this.level;
+    } else {
+    //  json[r'level'] = null;
+    }
+    if (this.readAt != null) {
+      json[r'readAt'] = this.readAt!.toUtc().toIso8601String();
+    } else {
+    //  json[r'readAt'] = null;
+    }
+      json[r'title'] = this.title;
+    if (this.type != null) {
+      json[r'type'] = this.type;
+    } else {
+    //  json[r'type'] = null;
+    }
+      json[r'userId'] = this.userId;
+    return json;
+  }
+
+  /// Returns a new [NotificationCreateDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static NotificationCreateDto? fromJson(dynamic value) {
+    upgradeDto(value, "NotificationCreateDto");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return NotificationCreateDto(
+        data: mapValueOfType<Object>(json, r'data'),
+        description: mapValueOfType<String>(json, r'description'),
+        level: NotificationLevel.fromJson(json[r'level']),
+        readAt: mapDateTime(json, r'readAt', r''),
+        title: mapValueOfType<String>(json, r'title')!,
+        type: NotificationType.fromJson(json[r'type']),
+        userId: mapValueOfType<String>(json, r'userId')!,
+      );
+    }
+    return null;
+  }
+
+  static List<NotificationCreateDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <NotificationCreateDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = NotificationCreateDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, NotificationCreateDto> mapFromJson(dynamic json) {
+    final map = <String, NotificationCreateDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = NotificationCreateDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of NotificationCreateDto-objects as value to a dart map
+  static Map<String, List<NotificationCreateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<NotificationCreateDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = NotificationCreateDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'title',
+    'userId',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/notification_delete_all_dto.dart b/mobile/openapi/lib/model/notification_delete_all_dto.dart
new file mode 100644
index 0000000000..4be1b89e92
--- /dev/null
+++ b/mobile/openapi/lib/model/notification_delete_all_dto.dart
@@ -0,0 +1,101 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class NotificationDeleteAllDto {
+  /// Returns a new [NotificationDeleteAllDto] instance.
+  NotificationDeleteAllDto({
+    this.ids = const [],
+  });
+
+  List<String> ids;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is NotificationDeleteAllDto &&
+    _deepEquality.equals(other.ids, ids);
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (ids.hashCode);
+
+  @override
+  String toString() => 'NotificationDeleteAllDto[ids=$ids]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'ids'] = this.ids;
+    return json;
+  }
+
+  /// Returns a new [NotificationDeleteAllDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static NotificationDeleteAllDto? fromJson(dynamic value) {
+    upgradeDto(value, "NotificationDeleteAllDto");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return NotificationDeleteAllDto(
+        ids: json[r'ids'] is Iterable
+            ? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
+            : const [],
+      );
+    }
+    return null;
+  }
+
+  static List<NotificationDeleteAllDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <NotificationDeleteAllDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = NotificationDeleteAllDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, NotificationDeleteAllDto> mapFromJson(dynamic json) {
+    final map = <String, NotificationDeleteAllDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = NotificationDeleteAllDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of NotificationDeleteAllDto-objects as value to a dart map
+  static Map<String, List<NotificationDeleteAllDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<NotificationDeleteAllDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = NotificationDeleteAllDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'ids',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/notification_dto.dart b/mobile/openapi/lib/model/notification_dto.dart
new file mode 100644
index 0000000000..4f730b4e50
--- /dev/null
+++ b/mobile/openapi/lib/model/notification_dto.dart
@@ -0,0 +1,182 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class NotificationDto {
+  /// Returns a new [NotificationDto] instance.
+  NotificationDto({
+    required this.createdAt,
+    this.data,
+    this.description,
+    required this.id,
+    required this.level,
+    this.readAt,
+    required this.title,
+    required this.type,
+  });
+
+  DateTime createdAt;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  Object? data;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? description;
+
+  String id;
+
+  NotificationLevel level;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  DateTime? readAt;
+
+  String title;
+
+  NotificationType type;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is NotificationDto &&
+    other.createdAt == createdAt &&
+    other.data == data &&
+    other.description == description &&
+    other.id == id &&
+    other.level == level &&
+    other.readAt == readAt &&
+    other.title == title &&
+    other.type == type;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (createdAt.hashCode) +
+    (data == null ? 0 : data!.hashCode) +
+    (description == null ? 0 : description!.hashCode) +
+    (id.hashCode) +
+    (level.hashCode) +
+    (readAt == null ? 0 : readAt!.hashCode) +
+    (title.hashCode) +
+    (type.hashCode);
+
+  @override
+  String toString() => 'NotificationDto[createdAt=$createdAt, data=$data, description=$description, id=$id, level=$level, readAt=$readAt, title=$title, type=$type]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
+    if (this.data != null) {
+      json[r'data'] = this.data;
+    } else {
+    //  json[r'data'] = null;
+    }
+    if (this.description != null) {
+      json[r'description'] = this.description;
+    } else {
+    //  json[r'description'] = null;
+    }
+      json[r'id'] = this.id;
+      json[r'level'] = this.level;
+    if (this.readAt != null) {
+      json[r'readAt'] = this.readAt!.toUtc().toIso8601String();
+    } else {
+    //  json[r'readAt'] = null;
+    }
+      json[r'title'] = this.title;
+      json[r'type'] = this.type;
+    return json;
+  }
+
+  /// Returns a new [NotificationDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static NotificationDto? fromJson(dynamic value) {
+    upgradeDto(value, "NotificationDto");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return NotificationDto(
+        createdAt: mapDateTime(json, r'createdAt', r'')!,
+        data: mapValueOfType<Object>(json, r'data'),
+        description: mapValueOfType<String>(json, r'description'),
+        id: mapValueOfType<String>(json, r'id')!,
+        level: NotificationLevel.fromJson(json[r'level'])!,
+        readAt: mapDateTime(json, r'readAt', r''),
+        title: mapValueOfType<String>(json, r'title')!,
+        type: NotificationType.fromJson(json[r'type'])!,
+      );
+    }
+    return null;
+  }
+
+  static List<NotificationDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <NotificationDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = NotificationDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, NotificationDto> mapFromJson(dynamic json) {
+    final map = <String, NotificationDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = NotificationDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of NotificationDto-objects as value to a dart map
+  static Map<String, List<NotificationDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<NotificationDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = NotificationDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'createdAt',
+    'id',
+    'level',
+    'title',
+    'type',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/notification_level.dart b/mobile/openapi/lib/model/notification_level.dart
new file mode 100644
index 0000000000..554863ae4f
--- /dev/null
+++ b/mobile/openapi/lib/model/notification_level.dart
@@ -0,0 +1,91 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+
+class NotificationLevel {
+  /// Instantiate a new enum with the provided [value].
+  const NotificationLevel._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const success = NotificationLevel._(r'success');
+  static const error = NotificationLevel._(r'error');
+  static const warning = NotificationLevel._(r'warning');
+  static const info = NotificationLevel._(r'info');
+
+  /// List of all possible values in this [enum][NotificationLevel].
+  static const values = <NotificationLevel>[
+    success,
+    error,
+    warning,
+    info,
+  ];
+
+  static NotificationLevel? fromJson(dynamic value) => NotificationLevelTypeTransformer().decode(value);
+
+  static List<NotificationLevel> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <NotificationLevel>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = NotificationLevel.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [NotificationLevel] to String,
+/// and [decode] dynamic data back to [NotificationLevel].
+class NotificationLevelTypeTransformer {
+  factory NotificationLevelTypeTransformer() => _instance ??= const NotificationLevelTypeTransformer._();
+
+  const NotificationLevelTypeTransformer._();
+
+  String encode(NotificationLevel data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a NotificationLevel.
+  ///
+  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+  ///
+  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+  /// and users are still using an old app with the old code.
+  NotificationLevel? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data) {
+        case r'success': return NotificationLevel.success;
+        case r'error': return NotificationLevel.error;
+        case r'warning': return NotificationLevel.warning;
+        case r'info': return NotificationLevel.info;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [NotificationLevelTypeTransformer] instance.
+  static NotificationLevelTypeTransformer? _instance;
+}
+
diff --git a/mobile/openapi/lib/model/notification_type.dart b/mobile/openapi/lib/model/notification_type.dart
new file mode 100644
index 0000000000..436d2d190f
--- /dev/null
+++ b/mobile/openapi/lib/model/notification_type.dart
@@ -0,0 +1,91 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+
+class NotificationType {
+  /// Instantiate a new enum with the provided [value].
+  const NotificationType._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const jobFailed = NotificationType._(r'JobFailed');
+  static const backupFailed = NotificationType._(r'BackupFailed');
+  static const systemMessage = NotificationType._(r'SystemMessage');
+  static const custom = NotificationType._(r'Custom');
+
+  /// List of all possible values in this [enum][NotificationType].
+  static const values = <NotificationType>[
+    jobFailed,
+    backupFailed,
+    systemMessage,
+    custom,
+  ];
+
+  static NotificationType? fromJson(dynamic value) => NotificationTypeTypeTransformer().decode(value);
+
+  static List<NotificationType> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <NotificationType>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = NotificationType.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [NotificationType] to String,
+/// and [decode] dynamic data back to [NotificationType].
+class NotificationTypeTypeTransformer {
+  factory NotificationTypeTypeTransformer() => _instance ??= const NotificationTypeTypeTransformer._();
+
+  const NotificationTypeTypeTransformer._();
+
+  String encode(NotificationType data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a NotificationType.
+  ///
+  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+  ///
+  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+  /// and users are still using an old app with the old code.
+  NotificationType? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data) {
+        case r'JobFailed': return NotificationType.jobFailed;
+        case r'BackupFailed': return NotificationType.backupFailed;
+        case r'SystemMessage': return NotificationType.systemMessage;
+        case r'Custom': return NotificationType.custom;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [NotificationTypeTypeTransformer] instance.
+  static NotificationTypeTypeTransformer? _instance;
+}
+
diff --git a/mobile/openapi/lib/model/notification_update_all_dto.dart b/mobile/openapi/lib/model/notification_update_all_dto.dart
new file mode 100644
index 0000000000..a6393b275a
--- /dev/null
+++ b/mobile/openapi/lib/model/notification_update_all_dto.dart
@@ -0,0 +1,112 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class NotificationUpdateAllDto {
+  /// Returns a new [NotificationUpdateAllDto] instance.
+  NotificationUpdateAllDto({
+    this.ids = const [],
+    this.readAt,
+  });
+
+  List<String> ids;
+
+  DateTime? readAt;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is NotificationUpdateAllDto &&
+    _deepEquality.equals(other.ids, ids) &&
+    other.readAt == readAt;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (ids.hashCode) +
+    (readAt == null ? 0 : readAt!.hashCode);
+
+  @override
+  String toString() => 'NotificationUpdateAllDto[ids=$ids, readAt=$readAt]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'ids'] = this.ids;
+    if (this.readAt != null) {
+      json[r'readAt'] = this.readAt!.toUtc().toIso8601String();
+    } else {
+    //  json[r'readAt'] = null;
+    }
+    return json;
+  }
+
+  /// Returns a new [NotificationUpdateAllDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static NotificationUpdateAllDto? fromJson(dynamic value) {
+    upgradeDto(value, "NotificationUpdateAllDto");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return NotificationUpdateAllDto(
+        ids: json[r'ids'] is Iterable
+            ? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
+            : const [],
+        readAt: mapDateTime(json, r'readAt', r''),
+      );
+    }
+    return null;
+  }
+
+  static List<NotificationUpdateAllDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <NotificationUpdateAllDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = NotificationUpdateAllDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, NotificationUpdateAllDto> mapFromJson(dynamic json) {
+    final map = <String, NotificationUpdateAllDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = NotificationUpdateAllDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of NotificationUpdateAllDto-objects as value to a dart map
+  static Map<String, List<NotificationUpdateAllDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<NotificationUpdateAllDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = NotificationUpdateAllDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'ids',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/notification_update_dto.dart b/mobile/openapi/lib/model/notification_update_dto.dart
new file mode 100644
index 0000000000..e76496eb97
--- /dev/null
+++ b/mobile/openapi/lib/model/notification_update_dto.dart
@@ -0,0 +1,102 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class NotificationUpdateDto {
+  /// Returns a new [NotificationUpdateDto] instance.
+  NotificationUpdateDto({
+    this.readAt,
+  });
+
+  DateTime? readAt;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is NotificationUpdateDto &&
+    other.readAt == readAt;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (readAt == null ? 0 : readAt!.hashCode);
+
+  @override
+  String toString() => 'NotificationUpdateDto[readAt=$readAt]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+    if (this.readAt != null) {
+      json[r'readAt'] = this.readAt!.toUtc().toIso8601String();
+    } else {
+    //  json[r'readAt'] = null;
+    }
+    return json;
+  }
+
+  /// Returns a new [NotificationUpdateDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static NotificationUpdateDto? fromJson(dynamic value) {
+    upgradeDto(value, "NotificationUpdateDto");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return NotificationUpdateDto(
+        readAt: mapDateTime(json, r'readAt', r''),
+      );
+    }
+    return null;
+  }
+
+  static List<NotificationUpdateDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <NotificationUpdateDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = NotificationUpdateDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, NotificationUpdateDto> mapFromJson(dynamic json) {
+    final map = <String, NotificationUpdateDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = NotificationUpdateDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of NotificationUpdateDto-objects as value to a dart map
+  static Map<String, List<NotificationUpdateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<NotificationUpdateDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = NotificationUpdateDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+  };
+}
+
diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart
index 1244a434b6..1735bc2eb5 100644
--- a/mobile/openapi/lib/model/permission.dart
+++ b/mobile/openapi/lib/model/permission.dart
@@ -66,6 +66,10 @@ class Permission {
   static const memoryPeriodRead = Permission._(r'memory.read');
   static const memoryPeriodUpdate = Permission._(r'memory.update');
   static const memoryPeriodDelete = Permission._(r'memory.delete');
+  static const notificationPeriodCreate = Permission._(r'notification.create');
+  static const notificationPeriodRead = Permission._(r'notification.read');
+  static const notificationPeriodUpdate = Permission._(r'notification.update');
+  static const notificationPeriodDelete = Permission._(r'notification.delete');
   static const partnerPeriodCreate = Permission._(r'partner.create');
   static const partnerPeriodRead = Permission._(r'partner.read');
   static const partnerPeriodUpdate = Permission._(r'partner.update');
@@ -147,6 +151,10 @@ class Permission {
     memoryPeriodRead,
     memoryPeriodUpdate,
     memoryPeriodDelete,
+    notificationPeriodCreate,
+    notificationPeriodRead,
+    notificationPeriodUpdate,
+    notificationPeriodDelete,
     partnerPeriodCreate,
     partnerPeriodRead,
     partnerPeriodUpdate,
@@ -263,6 +271,10 @@ class PermissionTypeTransformer {
         case r'memory.read': return Permission.memoryPeriodRead;
         case r'memory.update': return Permission.memoryPeriodUpdate;
         case r'memory.delete': return Permission.memoryPeriodDelete;
+        case r'notification.create': return Permission.notificationPeriodCreate;
+        case r'notification.read': return Permission.notificationPeriodRead;
+        case r'notification.update': return Permission.notificationPeriodUpdate;
+        case r'notification.delete': return Permission.notificationPeriodDelete;
         case r'partner.create': return Permission.partnerPeriodCreate;
         case r'partner.read': return Permission.partnerPeriodRead;
         case r'partner.update': return Permission.partnerPeriodUpdate;
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 1471020cd4..f4ec929373 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -206,6 +206,141 @@
         ]
       }
     },
+    "/admin/notifications": {
+      "post": {
+        "operationId": "createNotification",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/NotificationCreateDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "201": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NotificationDto"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Notifications (Admin)"
+        ]
+      }
+    },
+    "/admin/notifications/templates/{name}": {
+      "post": {
+        "operationId": "getNotificationTemplateAdmin",
+        "parameters": [
+          {
+            "name": "name",
+            "required": true,
+            "in": "path",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/TemplateDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/TemplateResponseDto"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Notifications (Admin)"
+        ]
+      }
+    },
+    "/admin/notifications/test-email": {
+      "post": {
+        "operationId": "sendTestEmailAdmin",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/SystemConfigSmtpDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/TestEmailResponseDto"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Notifications (Admin)"
+        ]
+      }
+    },
     "/admin/users": {
       "get": {
         "operationId": "searchUsersAdmin",
@@ -3485,15 +3620,224 @@
         ]
       }
     },
-    "/notifications/admin/templates/{name}": {
-      "post": {
-        "operationId": "getNotificationTemplateAdmin",
+    "/notifications": {
+      "delete": {
+        "operationId": "deleteNotifications",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/NotificationDeleteAllDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Notifications"
+        ]
+      },
+      "get": {
+        "operationId": "getNotifications",
         "parameters": [
           {
-            "name": "name",
+            "name": "id",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          },
+          {
+            "name": "level",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "$ref": "#/components/schemas/NotificationLevel"
+            }
+          },
+          {
+            "name": "type",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "$ref": "#/components/schemas/NotificationType"
+            }
+          },
+          {
+            "name": "unread",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "boolean"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "items": {
+                    "$ref": "#/components/schemas/NotificationDto"
+                  },
+                  "type": "array"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Notifications"
+        ]
+      },
+      "put": {
+        "operationId": "updateNotifications",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/NotificationUpdateAllDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Notifications"
+        ]
+      }
+    },
+    "/notifications/{id}": {
+      "delete": {
+        "operationId": "deleteNotification",
+        "parameters": [
+          {
+            "name": "id",
             "required": true,
             "in": "path",
             "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Notifications"
+        ]
+      },
+      "get": {
+        "operationId": "getNotification",
+        "parameters": [
+          {
+            "name": "id",
+            "required": true,
+            "in": "path",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NotificationDto"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Notifications"
+        ]
+      },
+      "put": {
+        "operationId": "updateNotification",
+        "parameters": [
+          {
+            "name": "id",
+            "required": true,
+            "in": "path",
+            "schema": {
+              "format": "uuid",
               "type": "string"
             }
           }
@@ -3502,7 +3846,7 @@
           "content": {
             "application/json": {
               "schema": {
-                "$ref": "#/components/schemas/TemplateDto"
+                "$ref": "#/components/schemas/NotificationUpdateDto"
               }
             }
           },
@@ -3513,7 +3857,7 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "$ref": "#/components/schemas/TemplateResponseDto"
+                  "$ref": "#/components/schemas/NotificationDto"
                 }
               }
             },
@@ -3532,49 +3876,7 @@
           }
         ],
         "tags": [
-          "Notifications (Admin)"
-        ]
-      }
-    },
-    "/notifications/admin/test-email": {
-      "post": {
-        "operationId": "sendTestEmailAdmin",
-        "parameters": [],
-        "requestBody": {
-          "content": {
-            "application/json": {
-              "schema": {
-                "$ref": "#/components/schemas/SystemConfigSmtpDto"
-              }
-            }
-          },
-          "required": true
-        },
-        "responses": {
-          "200": {
-            "content": {
-              "application/json": {
-                "schema": {
-                  "$ref": "#/components/schemas/TestEmailResponseDto"
-                }
-              }
-            },
-            "description": ""
-          }
-        },
-        "security": [
-          {
-            "bearer": []
-          },
-          {
-            "cookie": []
-          },
-          {
-            "api_key": []
-          }
-        ],
-        "tags": [
-          "Notifications (Admin)"
+          "Notifications"
         ]
       }
     },
@@ -10326,6 +10628,157 @@
         },
         "type": "object"
       },
+      "NotificationCreateDto": {
+        "properties": {
+          "data": {
+            "type": "object"
+          },
+          "description": {
+            "nullable": true,
+            "type": "string"
+          },
+          "level": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/NotificationLevel"
+              }
+            ]
+          },
+          "readAt": {
+            "format": "date-time",
+            "nullable": true,
+            "type": "string"
+          },
+          "title": {
+            "type": "string"
+          },
+          "type": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/NotificationType"
+              }
+            ]
+          },
+          "userId": {
+            "format": "uuid",
+            "type": "string"
+          }
+        },
+        "required": [
+          "title",
+          "userId"
+        ],
+        "type": "object"
+      },
+      "NotificationDeleteAllDto": {
+        "properties": {
+          "ids": {
+            "items": {
+              "format": "uuid",
+              "type": "string"
+            },
+            "type": "array"
+          }
+        },
+        "required": [
+          "ids"
+        ],
+        "type": "object"
+      },
+      "NotificationDto": {
+        "properties": {
+          "createdAt": {
+            "format": "date-time",
+            "type": "string"
+          },
+          "data": {
+            "type": "object"
+          },
+          "description": {
+            "type": "string"
+          },
+          "id": {
+            "type": "string"
+          },
+          "level": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/NotificationLevel"
+              }
+            ]
+          },
+          "readAt": {
+            "format": "date-time",
+            "type": "string"
+          },
+          "title": {
+            "type": "string"
+          },
+          "type": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/NotificationType"
+              }
+            ]
+          }
+        },
+        "required": [
+          "createdAt",
+          "id",
+          "level",
+          "title",
+          "type"
+        ],
+        "type": "object"
+      },
+      "NotificationLevel": {
+        "enum": [
+          "success",
+          "error",
+          "warning",
+          "info"
+        ],
+        "type": "string"
+      },
+      "NotificationType": {
+        "enum": [
+          "JobFailed",
+          "BackupFailed",
+          "SystemMessage",
+          "Custom"
+        ],
+        "type": "string"
+      },
+      "NotificationUpdateAllDto": {
+        "properties": {
+          "ids": {
+            "items": {
+              "format": "uuid",
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "readAt": {
+            "format": "date-time",
+            "nullable": true,
+            "type": "string"
+          }
+        },
+        "required": [
+          "ids"
+        ],
+        "type": "object"
+      },
+      "NotificationUpdateDto": {
+        "properties": {
+          "readAt": {
+            "format": "date-time",
+            "nullable": true,
+            "type": "string"
+          }
+        },
+        "type": "object"
+      },
       "OAuthAuthorizeResponseDto": {
         "properties": {
           "url": {
@@ -10600,6 +11053,10 @@
           "memory.read",
           "memory.update",
           "memory.delete",
+          "notification.create",
+          "notification.read",
+          "notification.update",
+          "notification.delete",
           "partner.create",
           "partner.read",
           "partner.update",
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index 1ba4d3e231..647c5c4ada 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -39,6 +39,48 @@ export type ActivityCreateDto = {
 export type ActivityStatisticsResponseDto = {
     comments: number;
 };
+export type NotificationCreateDto = {
+    data?: object;
+    description?: string | null;
+    level?: NotificationLevel;
+    readAt?: string | null;
+    title: string;
+    "type"?: NotificationType;
+    userId: string;
+};
+export type NotificationDto = {
+    createdAt: string;
+    data?: object;
+    description?: string;
+    id: string;
+    level: NotificationLevel;
+    readAt?: string;
+    title: string;
+    "type": NotificationType;
+};
+export type TemplateDto = {
+    template: string;
+};
+export type TemplateResponseDto = {
+    html: string;
+    name: string;
+};
+export type SystemConfigSmtpTransportDto = {
+    host: string;
+    ignoreCert: boolean;
+    password: string;
+    port: number;
+    username: string;
+};
+export type SystemConfigSmtpDto = {
+    enabled: boolean;
+    "from": string;
+    replyTo: string;
+    transport: SystemConfigSmtpTransportDto;
+};
+export type TestEmailResponseDto = {
+    messageId: string;
+};
 export type UserLicense = {
     activatedAt: string;
     activationKey: string;
@@ -661,28 +703,15 @@ export type MemoryUpdateDto = {
     memoryAt?: string;
     seenAt?: string;
 };
-export type TemplateDto = {
-    template: string;
+export type NotificationDeleteAllDto = {
+    ids: string[];
 };
-export type TemplateResponseDto = {
-    html: string;
-    name: string;
+export type NotificationUpdateAllDto = {
+    ids: string[];
+    readAt?: string | null;
 };
-export type SystemConfigSmtpTransportDto = {
-    host: string;
-    ignoreCert: boolean;
-    password: string;
-    port: number;
-    username: string;
-};
-export type SystemConfigSmtpDto = {
-    enabled: boolean;
-    "from": string;
-    replyTo: string;
-    transport: SystemConfigSmtpTransportDto;
-};
-export type TestEmailResponseDto = {
-    messageId: string;
+export type NotificationUpdateDto = {
+    readAt?: string | null;
 };
 export type OAuthConfigDto = {
     codeChallenge?: string;
@@ -1453,6 +1482,43 @@ export function deleteActivity({ id }: {
         method: "DELETE"
     }));
 }
+export function createNotification({ notificationCreateDto }: {
+    notificationCreateDto: NotificationCreateDto;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchJson<{
+        status: 201;
+        data: NotificationDto;
+    }>("/admin/notifications", oazapfts.json({
+        ...opts,
+        method: "POST",
+        body: notificationCreateDto
+    })));
+}
+export function getNotificationTemplateAdmin({ name, templateDto }: {
+    name: string;
+    templateDto: TemplateDto;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchJson<{
+        status: 200;
+        data: TemplateResponseDto;
+    }>(`/admin/notifications/templates/${encodeURIComponent(name)}`, oazapfts.json({
+        ...opts,
+        method: "POST",
+        body: templateDto
+    })));
+}
+export function sendTestEmailAdmin({ systemConfigSmtpDto }: {
+    systemConfigSmtpDto: SystemConfigSmtpDto;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchJson<{
+        status: 200;
+        data: TestEmailResponseDto;
+    }>("/admin/notifications/test-email", oazapfts.json({
+        ...opts,
+        method: "POST",
+        body: systemConfigSmtpDto
+    })));
+}
 export function searchUsersAdmin({ withDeleted }: {
     withDeleted?: boolean;
 }, opts?: Oazapfts.RequestOpts) {
@@ -2321,29 +2387,71 @@ export function addMemoryAssets({ id, bulkIdsDto }: {
         body: bulkIdsDto
     })));
 }
-export function getNotificationTemplateAdmin({ name, templateDto }: {
-    name: string;
-    templateDto: TemplateDto;
+export function deleteNotifications({ notificationDeleteAllDto }: {
+    notificationDeleteAllDto: NotificationDeleteAllDto;
 }, opts?: Oazapfts.RequestOpts) {
-    return oazapfts.ok(oazapfts.fetchJson<{
-        status: 200;
-        data: TemplateResponseDto;
-    }>(`/notifications/admin/templates/${encodeURIComponent(name)}`, oazapfts.json({
+    return oazapfts.ok(oazapfts.fetchText("/notifications", oazapfts.json({
         ...opts,
-        method: "POST",
-        body: templateDto
+        method: "DELETE",
+        body: notificationDeleteAllDto
     })));
 }
-export function sendTestEmailAdmin({ systemConfigSmtpDto }: {
-    systemConfigSmtpDto: SystemConfigSmtpDto;
+export function getNotifications({ id, level, $type, unread }: {
+    id?: string;
+    level?: NotificationLevel;
+    $type?: NotificationType;
+    unread?: boolean;
 }, opts?: Oazapfts.RequestOpts) {
     return oazapfts.ok(oazapfts.fetchJson<{
         status: 200;
-        data: TestEmailResponseDto;
-    }>("/notifications/admin/test-email", oazapfts.json({
+        data: NotificationDto[];
+    }>(`/notifications${QS.query(QS.explode({
+        id,
+        level,
+        "type": $type,
+        unread
+    }))}`, {
+        ...opts
+    }));
+}
+export function updateNotifications({ notificationUpdateAllDto }: {
+    notificationUpdateAllDto: NotificationUpdateAllDto;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchText("/notifications", oazapfts.json({
         ...opts,
-        method: "POST",
-        body: systemConfigSmtpDto
+        method: "PUT",
+        body: notificationUpdateAllDto
+    })));
+}
+export function deleteNotification({ id }: {
+    id: string;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchText(`/notifications/${encodeURIComponent(id)}`, {
+        ...opts,
+        method: "DELETE"
+    }));
+}
+export function getNotification({ id }: {
+    id: string;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchJson<{
+        status: 200;
+        data: NotificationDto;
+    }>(`/notifications/${encodeURIComponent(id)}`, {
+        ...opts
+    }));
+}
+export function updateNotification({ id, notificationUpdateDto }: {
+    id: string;
+    notificationUpdateDto: NotificationUpdateDto;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchJson<{
+        status: 200;
+        data: NotificationDto;
+    }>(`/notifications/${encodeURIComponent(id)}`, oazapfts.json({
+        ...opts,
+        method: "PUT",
+        body: notificationUpdateDto
     })));
 }
 export function startOAuth({ oAuthConfigDto }: {
@@ -3452,6 +3560,18 @@ export enum UserAvatarColor {
     Gray = "gray",
     Amber = "amber"
 }
+export enum NotificationLevel {
+    Success = "success",
+    Error = "error",
+    Warning = "warning",
+    Info = "info"
+}
+export enum NotificationType {
+    JobFailed = "JobFailed",
+    BackupFailed = "BackupFailed",
+    SystemMessage = "SystemMessage",
+    Custom = "Custom"
+}
 export enum UserStatus {
     Active = "active",
     Removing = "removing",
@@ -3526,6 +3646,10 @@ export enum Permission {
     MemoryRead = "memory.read",
     MemoryUpdate = "memory.update",
     MemoryDelete = "memory.delete",
+    NotificationCreate = "notification.create",
+    NotificationRead = "notification.read",
+    NotificationUpdate = "notification.update",
+    NotificationDelete = "notification.delete",
     PartnerCreate = "partner.create",
     PartnerRead = "partner.read",
     PartnerUpdate = "partner.update",
diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts
index 0da0aac8b1..e36793b3d7 100644
--- a/server/src/controllers/index.ts
+++ b/server/src/controllers/index.ts
@@ -14,6 +14,7 @@ import { LibraryController } from 'src/controllers/library.controller';
 import { MapController } from 'src/controllers/map.controller';
 import { MemoryController } from 'src/controllers/memory.controller';
 import { NotificationAdminController } from 'src/controllers/notification-admin.controller';
+import { NotificationController } from 'src/controllers/notification.controller';
 import { OAuthController } from 'src/controllers/oauth.controller';
 import { PartnerController } from 'src/controllers/partner.controller';
 import { PersonController } from 'src/controllers/person.controller';
@@ -47,6 +48,7 @@ export const controllers = [
   LibraryController,
   MapController,
   MemoryController,
+  NotificationController,
   NotificationAdminController,
   OAuthController,
   PartnerController,
diff --git a/server/src/controllers/notification-admin.controller.ts b/server/src/controllers/notification-admin.controller.ts
index 937244fc56..9bac865bdf 100644
--- a/server/src/controllers/notification-admin.controller.ts
+++ b/server/src/controllers/notification-admin.controller.ts
@@ -1,16 +1,28 @@
 import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
 import { ApiTags } from '@nestjs/swagger';
 import { AuthDto } from 'src/dtos/auth.dto';
-import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto';
+import {
+  NotificationCreateDto,
+  NotificationDto,
+  TemplateDto,
+  TemplateResponseDto,
+  TestEmailResponseDto,
+} from 'src/dtos/notification.dto';
 import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
 import { Auth, Authenticated } from 'src/middleware/auth.guard';
 import { EmailTemplate } from 'src/repositories/email.repository';
-import { NotificationService } from 'src/services/notification.service';
+import { NotificationAdminService } from 'src/services/notification-admin.service';
 
 @ApiTags('Notifications (Admin)')
-@Controller('notifications/admin')
+@Controller('admin/notifications')
 export class NotificationAdminController {
-  constructor(private service: NotificationService) {}
+  constructor(private service: NotificationAdminService) {}
+
+  @Post()
+  @Authenticated({ admin: true })
+  createNotification(@Auth() auth: AuthDto, @Body() dto: NotificationCreateDto): Promise<NotificationDto> {
+    return this.service.create(auth, dto);
+  }
 
   @Post('test-email')
   @HttpCode(HttpStatus.OK)
diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts
new file mode 100644
index 0000000000..c64f786850
--- /dev/null
+++ b/server/src/controllers/notification.controller.ts
@@ -0,0 +1,60 @@
+import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common';
+import { ApiTags } from '@nestjs/swagger';
+import { AuthDto } from 'src/dtos/auth.dto';
+import {
+  NotificationDeleteAllDto,
+  NotificationDto,
+  NotificationSearchDto,
+  NotificationUpdateAllDto,
+  NotificationUpdateDto,
+} from 'src/dtos/notification.dto';
+import { Permission } from 'src/enum';
+import { Auth, Authenticated } from 'src/middleware/auth.guard';
+import { NotificationService } from 'src/services/notification.service';
+import { UUIDParamDto } from 'src/validation';
+
+@ApiTags('Notifications')
+@Controller('notifications')
+export class NotificationController {
+  constructor(private service: NotificationService) {}
+
+  @Get()
+  @Authenticated({ permission: Permission.NOTIFICATION_READ })
+  getNotifications(@Auth() auth: AuthDto, @Query() dto: NotificationSearchDto): Promise<NotificationDto[]> {
+    return this.service.search(auth, dto);
+  }
+
+  @Put()
+  @Authenticated({ permission: Permission.NOTIFICATION_UPDATE })
+  updateNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationUpdateAllDto): Promise<void> {
+    return this.service.updateAll(auth, dto);
+  }
+
+  @Delete()
+  @Authenticated({ permission: Permission.NOTIFICATION_DELETE })
+  deleteNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationDeleteAllDto): Promise<void> {
+    return this.service.deleteAll(auth, dto);
+  }
+
+  @Get(':id')
+  @Authenticated({ permission: Permission.NOTIFICATION_READ })
+  getNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<NotificationDto> {
+    return this.service.get(auth, id);
+  }
+
+  @Put(':id')
+  @Authenticated({ permission: Permission.NOTIFICATION_UPDATE })
+  updateNotification(
+    @Auth() auth: AuthDto,
+    @Param() { id }: UUIDParamDto,
+    @Body() dto: NotificationUpdateDto,
+  ): Promise<NotificationDto> {
+    return this.service.update(auth, id, dto);
+  }
+
+  @Delete(':id')
+  @Authenticated({ permission: Permission.NOTIFICATION_DELETE })
+  deleteNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
+    return this.service.delete(auth, id);
+  }
+}
diff --git a/server/src/database.ts b/server/src/database.ts
index 0dab61cbe0..a93873ef42 100644
--- a/server/src/database.ts
+++ b/server/src/database.ts
@@ -333,6 +333,7 @@ export const columns = {
   ],
   tag: ['tags.id', 'tags.value', 'tags.createdAt', 'tags.updatedAt', 'tags.color', 'tags.parentId'],
   apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
+  notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'],
   syncAsset: [
     'id',
     'ownerId',
diff --git a/server/src/db.d.ts b/server/src/db.d.ts
index 4e9738ecec..85be9d5208 100644
--- a/server/src/db.d.ts
+++ b/server/src/db.d.ts
@@ -11,6 +11,8 @@ import {
   AssetStatus,
   AssetType,
   MemoryType,
+  NotificationLevel,
+  NotificationType,
   Permission,
   SharedLinkType,
   SourceType,
@@ -263,6 +265,21 @@ export interface Memories {
   updateId: Generated<string>;
 }
 
+export interface Notifications {
+  id: Generated<string>;
+  createdAt: Generated<Timestamp>;
+  updatedAt: Generated<Timestamp>;
+  deletedAt: Timestamp | null;
+  updateId: Generated<string>;
+  userId: string;
+  level: Generated<NotificationLevel>;
+  type: NotificationType;
+  title: string;
+  description: string | null;
+  data: any | null;
+  readAt: Timestamp | null;
+}
+
 export interface MemoriesAssetsAssets {
   assetsId: string;
   memoriesId: string;
@@ -463,6 +480,7 @@ export interface DB {
   memories: Memories;
   memories_assets_assets: MemoriesAssetsAssets;
   migrations: Migrations;
+  notifications: Notifications;
   move_history: MoveHistory;
   naturalearth_countries: NaturalearthCountries;
   partners_audit: PartnersAudit;
diff --git a/server/src/dtos/notification.dto.ts b/server/src/dtos/notification.dto.ts
index c1a09c801c..d9847cda17 100644
--- a/server/src/dtos/notification.dto.ts
+++ b/server/src/dtos/notification.dto.ts
@@ -1,4 +1,7 @@
-import { IsString } from 'class-validator';
+import { ApiProperty } from '@nestjs/swagger';
+import { IsEnum, IsString } from 'class-validator';
+import { NotificationLevel, NotificationType } from 'src/enum';
+import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
 
 export class TestEmailResponseDto {
   messageId!: string;
@@ -11,3 +14,106 @@ export class TemplateDto {
   @IsString()
   template!: string;
 }
+
+export class NotificationDto {
+  id!: string;
+  @ValidateDate()
+  createdAt!: Date;
+  @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' })
+  level!: NotificationLevel;
+  @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' })
+  type!: NotificationType;
+  title!: string;
+  description?: string;
+  data?: any;
+  readAt?: Date;
+}
+
+export class NotificationSearchDto {
+  @Optional()
+  @ValidateUUID({ optional: true })
+  id?: string;
+
+  @IsEnum(NotificationLevel)
+  @Optional()
+  @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' })
+  level?: NotificationLevel;
+
+  @IsEnum(NotificationType)
+  @Optional()
+  @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' })
+  type?: NotificationType;
+
+  @ValidateBoolean({ optional: true })
+  unread?: boolean;
+}
+
+export class NotificationCreateDto {
+  @Optional()
+  @IsEnum(NotificationLevel)
+  @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' })
+  level?: NotificationLevel;
+
+  @IsEnum(NotificationType)
+  @Optional()
+  @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' })
+  type?: NotificationType;
+
+  @IsString()
+  title!: string;
+
+  @IsString()
+  @Optional({ nullable: true })
+  description?: string | null;
+
+  @Optional({ nullable: true })
+  data?: any;
+
+  @ValidateDate({ optional: true, nullable: true })
+  readAt?: Date | null;
+
+  @ValidateUUID()
+  userId!: string;
+}
+
+export class NotificationUpdateDto {
+  @ValidateDate({ optional: true, nullable: true })
+  readAt?: Date | null;
+}
+
+export class NotificationUpdateAllDto {
+  @ValidateUUID({ each: true, optional: true })
+  ids!: string[];
+
+  @ValidateDate({ optional: true, nullable: true })
+  readAt?: Date | null;
+}
+
+export class NotificationDeleteAllDto {
+  @ValidateUUID({ each: true })
+  ids!: string[];
+}
+
+export type MapNotification = {
+  id: string;
+  createdAt: Date;
+  updateId?: string;
+  level: NotificationLevel;
+  type: NotificationType;
+  data: any | null;
+  title: string;
+  description: string | null;
+  readAt: Date | null;
+};
+export const mapNotification = (notification: MapNotification): NotificationDto => {
+  return {
+    id: notification.id,
+    createdAt: notification.createdAt,
+    level: notification.level,
+    type: notification.type,
+    title: notification.title,
+    description: notification.description ?? undefined,
+    data: notification.data ?? undefined,
+    readAt: notification.readAt ?? undefined,
+  };
+};
diff --git a/server/src/enum.ts b/server/src/enum.ts
index b9a914671a..9fb6168b1a 100644
--- a/server/src/enum.ts
+++ b/server/src/enum.ts
@@ -126,6 +126,11 @@ export enum Permission {
   MEMORY_UPDATE = 'memory.update',
   MEMORY_DELETE = 'memory.delete',
 
+  NOTIFICATION_CREATE = 'notification.create',
+  NOTIFICATION_READ = 'notification.read',
+  NOTIFICATION_UPDATE = 'notification.update',
+  NOTIFICATION_DELETE = 'notification.delete',
+
   PARTNER_CREATE = 'partner.create',
   PARTNER_READ = 'partner.read',
   PARTNER_UPDATE = 'partner.update',
@@ -515,6 +520,7 @@ export enum JobName {
   NOTIFY_SIGNUP = 'notify-signup',
   NOTIFY_ALBUM_INVITE = 'notify-album-invite',
   NOTIFY_ALBUM_UPDATE = 'notify-album-update',
+  NOTIFICATIONS_CLEANUP = 'notifications-cleanup',
   SEND_EMAIL = 'notification-send-email',
 
   // Version check
@@ -580,3 +586,17 @@ export enum SyncEntityType {
   PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1',
   PartnerAssetExifV1 = 'PartnerAssetExifV1',
 }
+
+export enum NotificationLevel {
+  Success = 'success',
+  Error = 'error',
+  Warning = 'warning',
+  Info = 'info',
+}
+
+export enum NotificationType {
+  JobFailed = 'JobFailed',
+  BackupFailed = 'BackupFailed',
+  SystemMessage = 'SystemMessage',
+  Custom = 'Custom',
+}
diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql
index dd58aebcb2..03f1af3b28 100644
--- a/server/src/queries/access.repository.sql
+++ b/server/src/queries/access.repository.sql
@@ -157,6 +157,15 @@ where
   and "memories"."ownerId" = $2
   and "memories"."deletedAt" is null
 
+-- AccessRepository.notification.checkOwnerAccess
+select
+  "notifications"."id"
+from
+  "notifications"
+where
+  "notifications"."id" in ($1)
+  and "notifications"."userId" = $2
+
 -- AccessRepository.person.checkOwnerAccess
 select
   "person"."id"
diff --git a/server/src/queries/notification.repository.sql b/server/src/queries/notification.repository.sql
new file mode 100644
index 0000000000..c55e00d226
--- /dev/null
+++ b/server/src/queries/notification.repository.sql
@@ -0,0 +1,58 @@
+-- NOTE: This file is auto generated by ./sql-generator
+
+-- NotificationRepository.cleanup
+delete from "notifications"
+where
+  (
+    (
+      "deletedAt" is not null
+      and "deletedAt" < $1
+    )
+    or (
+      "readAt" > $2
+      and "createdAt" < $3
+    )
+    or (
+      "readAt" = $4
+      and "createdAt" < $5
+    )
+  )
+
+-- NotificationRepository.search
+select
+  "id",
+  "createdAt",
+  "level",
+  "type",
+  "title",
+  "description",
+  "data",
+  "readAt"
+from
+  "notifications"
+where
+  "userId" = $1
+  and "deletedAt" is null
+order by
+  "createdAt" desc
+
+-- NotificationRepository.search (unread)
+select
+  "id",
+  "createdAt",
+  "level",
+  "type",
+  "title",
+  "description",
+  "data",
+  "readAt"
+from
+  "notifications"
+where
+  (
+    "userId" = $1
+    and "readAt" is null
+  )
+  and "deletedAt" is null
+order by
+  "createdAt" desc
diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts
index 961cccbf3e..c24209e482 100644
--- a/server/src/repositories/access.repository.ts
+++ b/server/src/repositories/access.repository.ts
@@ -279,6 +279,26 @@ class AuthDeviceAccess {
   }
 }
 
+class NotificationAccess {
+  constructor(private db: Kysely<DB>) {}
+
+  @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
+  @ChunkedSet({ paramIndex: 1 })
+  async checkOwnerAccess(userId: string, notificationIds: Set<string>) {
+    if (notificationIds.size === 0) {
+      return new Set<string>();
+    }
+
+    return this.db
+      .selectFrom('notifications')
+      .select('notifications.id')
+      .where('notifications.id', 'in', [...notificationIds])
+      .where('notifications.userId', '=', userId)
+      .execute()
+      .then((stacks) => new Set(stacks.map((stack) => stack.id)));
+  }
+}
+
 class StackAccess {
   constructor(private db: Kysely<DB>) {}
 
@@ -426,6 +446,7 @@ export class AccessRepository {
   asset: AssetAccess;
   authDevice: AuthDeviceAccess;
   memory: MemoryAccess;
+  notification: NotificationAccess;
   person: PersonAccess;
   partner: PartnerAccess;
   stack: StackAccess;
@@ -438,6 +459,7 @@ export class AccessRepository {
     this.asset = new AssetAccess(db);
     this.authDevice = new AuthDeviceAccess(db);
     this.memory = new MemoryAccess(db);
+    this.notification = new NotificationAccess(db);
     this.person = new PersonAccess(db);
     this.partner = new PartnerAccess(db);
     this.stack = new StackAccess(db);
diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts
index 3156804d09..b41c007ef5 100644
--- a/server/src/repositories/event.repository.ts
+++ b/server/src/repositories/event.repository.ts
@@ -14,6 +14,7 @@ import { SystemConfig } from 'src/config';
 import { EventConfig } from 'src/decorators';
 import { AssetResponseDto } from 'src/dtos/asset-response.dto';
 import { AuthDto } from 'src/dtos/auth.dto';
+import { NotificationDto } from 'src/dtos/notification.dto';
 import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
 import { ImmichWorker, MetadataKey, QueueName } from 'src/enum';
 import { ConfigRepository } from 'src/repositories/config.repository';
@@ -64,6 +65,7 @@ type EventMap = {
   'assets.restore': [{ assetIds: string[]; userId: string }];
 
   'job.start': [QueueName, JobItem];
+  'job.failed': [{ job: JobItem; error: Error | any }];
 
   // session events
   'session.delete': [{ sessionId: string }];
@@ -104,6 +106,7 @@ export interface ClientEventMap {
   on_server_version: [ServerVersionResponseDto];
   on_config_update: [];
   on_new_release: [ReleaseNotification];
+  on_notification: [NotificationDto];
   on_session_delete: [string];
 }
 
diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts
index bd2e5c6774..453e515fe0 100644
--- a/server/src/repositories/index.ts
+++ b/server/src/repositories/index.ts
@@ -22,6 +22,7 @@ import { MediaRepository } from 'src/repositories/media.repository';
 import { MemoryRepository } from 'src/repositories/memory.repository';
 import { MetadataRepository } from 'src/repositories/metadata.repository';
 import { MoveRepository } from 'src/repositories/move.repository';
+import { NotificationRepository } from 'src/repositories/notification.repository';
 import { OAuthRepository } from 'src/repositories/oauth.repository';
 import { PartnerRepository } from 'src/repositories/partner.repository';
 import { PersonRepository } from 'src/repositories/person.repository';
@@ -55,6 +56,7 @@ export const repositories = [
   CryptoRepository,
   DatabaseRepository,
   DownloadRepository,
+  EmailRepository,
   EventRepository,
   JobRepository,
   LibraryRepository,
@@ -65,7 +67,7 @@ export const repositories = [
   MemoryRepository,
   MetadataRepository,
   MoveRepository,
-  EmailRepository,
+  NotificationRepository,
   OAuthRepository,
   PartnerRepository,
   PersonRepository,
diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts
new file mode 100644
index 0000000000..112bb97e60
--- /dev/null
+++ b/server/src/repositories/notification.repository.ts
@@ -0,0 +1,103 @@
+import { Insertable, Kysely, Updateable } from 'kysely';
+import { DateTime } from 'luxon';
+import { InjectKysely } from 'nestjs-kysely';
+import { columns } from 'src/database';
+import { DB, Notifications } from 'src/db';
+import { DummyValue, GenerateSql } from 'src/decorators';
+import { NotificationSearchDto } from 'src/dtos/notification.dto';
+
+export class NotificationRepository {
+  constructor(@InjectKysely() private db: Kysely<DB>) {}
+
+  @GenerateSql({ params: [DummyValue.UUID] })
+  cleanup() {
+    return this.db
+      .deleteFrom('notifications')
+      .where((eb) =>
+        eb.or([
+          // remove soft-deleted notifications
+          eb.and([eb('deletedAt', 'is not', null), eb('deletedAt', '<', DateTime.now().minus({ days: 3 }).toJSDate())]),
+
+          // remove old, read notifications
+          eb.and([
+            // keep recently read messages around for a few days
+            eb('readAt', '>', DateTime.now().minus({ days: 2 }).toJSDate()),
+            eb('createdAt', '<', DateTime.now().minus({ days: 15 }).toJSDate()),
+          ]),
+
+          eb.and([
+            // remove super old, unread notifications
+            eb('readAt', '=', null),
+            eb('createdAt', '<', DateTime.now().minus({ days: 30 }).toJSDate()),
+          ]),
+        ]),
+      )
+      .execute();
+  }
+
+  @GenerateSql({ params: [DummyValue.UUID, {}] }, { name: 'unread', params: [DummyValue.UUID, { unread: true }] })
+  search(userId: string, dto: NotificationSearchDto) {
+    return this.db
+      .selectFrom('notifications')
+      .select(columns.notification)
+      .where((qb) =>
+        qb.and({
+          userId,
+          id: dto.id,
+          level: dto.level,
+          type: dto.type,
+          readAt: dto.unread ? null : undefined,
+        }),
+      )
+      .where('deletedAt', 'is', null)
+      .orderBy('createdAt', 'desc')
+      .execute();
+  }
+
+  create(notification: Insertable<Notifications>) {
+    return this.db
+      .insertInto('notifications')
+      .values(notification)
+      .returning(columns.notification)
+      .executeTakeFirstOrThrow();
+  }
+
+  get(id: string) {
+    return this.db
+      .selectFrom('notifications')
+      .select(columns.notification)
+      .where('id', '=', id)
+      .where('deletedAt', 'is not', null)
+      .executeTakeFirst();
+  }
+
+  update(id: string, notification: Updateable<Notifications>) {
+    return this.db
+      .updateTable('notifications')
+      .set(notification)
+      .where('deletedAt', 'is', null)
+      .where('id', '=', id)
+      .returning(columns.notification)
+      .executeTakeFirstOrThrow();
+  }
+
+  async updateAll(ids: string[], notification: Updateable<Notifications>) {
+    await this.db.updateTable('notifications').set(notification).where('id', 'in', ids).execute();
+  }
+
+  async delete(id: string) {
+    await this.db
+      .updateTable('notifications')
+      .set({ deletedAt: DateTime.now().toJSDate() })
+      .where('id', '=', id)
+      .execute();
+  }
+
+  async deleteAll(ids: string[]) {
+    await this.db
+      .updateTable('notifications')
+      .set({ deletedAt: DateTime.now().toJSDate() })
+      .where('id', 'in', ids)
+      .execute();
+  }
+}
diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts
index fe4b86d65c..d297b2217d 100644
--- a/server/src/schema/index.ts
+++ b/server/src/schema/index.ts
@@ -28,6 +28,7 @@ import { MemoryTable } from 'src/schema/tables/memory.table';
 import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table';
 import { MoveTable } from 'src/schema/tables/move.table';
 import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
+import { NotificationTable } from 'src/schema/tables/notification.table';
 import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
 import { PartnerTable } from 'src/schema/tables/partner.table';
 import { PersonTable } from 'src/schema/tables/person.table';
@@ -76,6 +77,7 @@ export class ImmichDatabase {
     MemoryTable,
     MoveTable,
     NaturalEarthCountriesTable,
+    NotificationTable,
     PartnerAuditTable,
     PartnerTable,
     PersonTable,
diff --git a/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts b/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts
new file mode 100644
index 0000000000..28dca6658c
--- /dev/null
+++ b/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts
@@ -0,0 +1,22 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely<any>): Promise<void> {
+  await sql`CREATE TABLE "notifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "deletedAt" timestamp with time zone, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), "userId" uuid, "level" character varying NOT NULL DEFAULT 'info', "type" character varying NOT NULL DEFAULT 'info', "data" jsonb, "title" character varying NOT NULL, "description" text, "readAt" timestamp with time zone);`.execute(db);
+  await sql`ALTER TABLE "notifications" ADD CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a" PRIMARY KEY ("id");`.execute(db);
+  await sql`ALTER TABLE "notifications" ADD CONSTRAINT "FK_692a909ee0fa9383e7859f9b406" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
+  await sql`CREATE INDEX "IDX_notifications_update_id" ON "notifications" ("updateId")`.execute(db);
+  await sql`CREATE INDEX "IDX_692a909ee0fa9383e7859f9b40" ON "notifications" ("userId")`.execute(db);
+  await sql`CREATE OR REPLACE TRIGGER "notifications_updated_at"
+  BEFORE UPDATE ON "notifications"
+  FOR EACH ROW
+  EXECUTE FUNCTION updated_at();`.execute(db);
+}
+
+export async function down(db: Kysely<any>): Promise<void> {
+  await sql`DROP TRIGGER "notifications_updated_at" ON "notifications";`.execute(db);
+  await sql`DROP INDEX "IDX_notifications_update_id";`.execute(db);
+  await sql`DROP INDEX "IDX_692a909ee0fa9383e7859f9b40";`.execute(db);
+  await sql`ALTER TABLE "notifications" DROP CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a";`.execute(db);
+  await sql`ALTER TABLE "notifications" DROP CONSTRAINT "FK_692a909ee0fa9383e7859f9b406";`.execute(db);
+  await sql`DROP TABLE "notifications";`.execute(db);
+}
diff --git a/server/src/schema/tables/notification.table.ts b/server/src/schema/tables/notification.table.ts
new file mode 100644
index 0000000000..bf9b8bdf3b
--- /dev/null
+++ b/server/src/schema/tables/notification.table.ts
@@ -0,0 +1,52 @@
+import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
+import { NotificationLevel, NotificationType } from 'src/enum';
+import { UserTable } from 'src/schema/tables/user.table';
+import {
+  Column,
+  CreateDateColumn,
+  DeleteDateColumn,
+  ForeignKeyColumn,
+  PrimaryGeneratedColumn,
+  Table,
+  UpdateDateColumn,
+} from 'src/sql-tools';
+
+@Table('notifications')
+@UpdatedAtTrigger('notifications_updated_at')
+export class NotificationTable {
+  @PrimaryGeneratedColumn()
+  id!: string;
+
+  @CreateDateColumn()
+  createdAt!: Date;
+
+  @UpdateDateColumn()
+  updatedAt!: Date;
+
+  @DeleteDateColumn()
+  deletedAt?: Date;
+
+  @UpdateIdColumn({ indexName: 'IDX_notifications_update_id' })
+  updateId?: string;
+
+  @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
+  userId!: string;
+
+  @Column({ default: NotificationLevel.Info })
+  level!: NotificationLevel;
+
+  @Column({ default: NotificationLevel.Info })
+  type!: NotificationType;
+
+  @Column({ type: 'jsonb', nullable: true })
+  data!: any | null;
+
+  @Column()
+  title!: string;
+
+  @Column({ type: 'text', nullable: true })
+  description!: string;
+
+  @Column({ type: 'timestamp with time zone', nullable: true })
+  readAt?: Date | null;
+}
diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts
index 704087ab05..aa72fd588a 100644
--- a/server/src/services/backup.service.spec.ts
+++ b/server/src/services/backup.service.spec.ts
@@ -142,52 +142,55 @@ describe(BackupService.name, () => {
       mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
       mocks.storage.createWriteStream.mockReturnValue(new PassThrough());
     });
+
     it('should run a database backup successfully', async () => {
       const result = await sut.handleBackupDatabase();
       expect(result).toBe(JobStatus.SUCCESS);
       expect(mocks.storage.createWriteStream).toHaveBeenCalled();
     });
+
     it('should rename file on success', async () => {
       const result = await sut.handleBackupDatabase();
       expect(result).toBe(JobStatus.SUCCESS);
       expect(mocks.storage.rename).toHaveBeenCalled();
     });
+
     it('should fail if pg_dumpall fails', async () => {
       mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
-      const result = await sut.handleBackupDatabase();
-      expect(result).toBe(JobStatus.FAILED);
+      await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
     });
+
     it('should not rename file if pgdump fails and gzip succeeds', async () => {
       mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
-      const result = await sut.handleBackupDatabase();
-      expect(result).toBe(JobStatus.FAILED);
+      await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
       expect(mocks.storage.rename).not.toHaveBeenCalled();
     });
+
     it('should fail if gzip fails', async () => {
       mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', ''));
       mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
-      const result = await sut.handleBackupDatabase();
-      expect(result).toBe(JobStatus.FAILED);
+      await expect(sut.handleBackupDatabase()).rejects.toThrow('Gzip failed with code 1');
     });
+
     it('should fail if write stream fails', async () => {
       mocks.storage.createWriteStream.mockImplementation(() => {
         throw new Error('error');
       });
-      const result = await sut.handleBackupDatabase();
-      expect(result).toBe(JobStatus.FAILED);
+      await expect(sut.handleBackupDatabase()).rejects.toThrow('error');
     });
+
     it('should fail if rename fails', async () => {
       mocks.storage.rename.mockRejectedValue(new Error('error'));
-      const result = await sut.handleBackupDatabase();
-      expect(result).toBe(JobStatus.FAILED);
+      await expect(sut.handleBackupDatabase()).rejects.toThrow('error');
     });
+
     it('should ignore unlink failing and still return failed job status', async () => {
       mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
       mocks.storage.unlink.mockRejectedValue(new Error('error'));
-      const result = await sut.handleBackupDatabase();
+      await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
       expect(mocks.storage.unlink).toHaveBeenCalled();
-      expect(result).toBe(JobStatus.FAILED);
     });
+
     it.each`
       postgresVersion                       | expectedVersion
       ${'14.10'}                            | ${14}
diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts
index 409d34ab73..10f7becc7d 100644
--- a/server/src/services/backup.service.ts
+++ b/server/src/services/backup.service.ts
@@ -174,7 +174,7 @@ export class BackupService extends BaseService {
       await this.storageRepository
         .unlink(backupFilePath)
         .catch((error) => this.logger.error('Failed to delete failed backup file', error));
-      return JobStatus.FAILED;
+      throw error;
     }
 
     this.logger.log(`Database Backup Success`);
diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts
index 23ddb1b63e..3381ad7222 100644
--- a/server/src/services/base.service.ts
+++ b/server/src/services/base.service.ts
@@ -29,6 +29,7 @@ import { MediaRepository } from 'src/repositories/media.repository';
 import { MemoryRepository } from 'src/repositories/memory.repository';
 import { MetadataRepository } from 'src/repositories/metadata.repository';
 import { MoveRepository } from 'src/repositories/move.repository';
+import { NotificationRepository } from 'src/repositories/notification.repository';
 import { OAuthRepository } from 'src/repositories/oauth.repository';
 import { PartnerRepository } from 'src/repositories/partner.repository';
 import { PersonRepository } from 'src/repositories/person.repository';
@@ -80,6 +81,7 @@ export class BaseService {
     protected memoryRepository: MemoryRepository,
     protected metadataRepository: MetadataRepository,
     protected moveRepository: MoveRepository,
+    protected notificationRepository: NotificationRepository,
     protected oauthRepository: OAuthRepository,
     protected partnerRepository: PartnerRepository,
     protected personRepository: PersonRepository,
diff --git a/server/src/services/index.ts b/server/src/services/index.ts
index b214dd14f6..88b68d2c13 100644
--- a/server/src/services/index.ts
+++ b/server/src/services/index.ts
@@ -17,6 +17,7 @@ import { MapService } from 'src/services/map.service';
 import { MediaService } from 'src/services/media.service';
 import { MemoryService } from 'src/services/memory.service';
 import { MetadataService } from 'src/services/metadata.service';
+import { NotificationAdminService } from 'src/services/notification-admin.service';
 import { NotificationService } from 'src/services/notification.service';
 import { PartnerService } from 'src/services/partner.service';
 import { PersonService } from 'src/services/person.service';
@@ -60,6 +61,7 @@ export const services = [
   MemoryService,
   MetadataService,
   NotificationService,
+  NotificationAdminService,
   PartnerService,
   PersonService,
   SearchService,
diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts
index b81256de81..a387e6e099 100644
--- a/server/src/services/job.service.ts
+++ b/server/src/services/job.service.ts
@@ -215,11 +215,7 @@ export class JobService extends BaseService {
         await this.onDone(job);
       }
     } catch (error: Error | any) {
-      this.logger.error(
-        `Unable to run job handler (${queueName}/${job.name}): ${error}`,
-        error?.stack,
-        JSON.stringify(job.data),
-      );
+      await this.eventRepository.emit('job.failed', { job, error });
     } finally {
       this.telemetryRepository.jobs.addToGauge(queueMetric, -1);
     }
diff --git a/server/src/services/notification-admin.service.spec.ts b/server/src/services/notification-admin.service.spec.ts
new file mode 100644
index 0000000000..4a747d41a3
--- /dev/null
+++ b/server/src/services/notification-admin.service.spec.ts
@@ -0,0 +1,111 @@
+import { defaults, SystemConfig } from 'src/config';
+import { EmailTemplate } from 'src/repositories/email.repository';
+import { NotificationService } from 'src/services/notification.service';
+import { userStub } from 'test/fixtures/user.stub';
+import { newTestService, ServiceMocks } from 'test/utils';
+
+const smtpTransport = Object.freeze<SystemConfig>({
+  ...defaults,
+  notifications: {
+    smtp: {
+      ...defaults.notifications.smtp,
+      enabled: true,
+      transport: {
+        ignoreCert: false,
+        host: 'localhost',
+        port: 587,
+        username: 'test',
+        password: 'test',
+      },
+    },
+  },
+});
+
+describe(NotificationService.name, () => {
+  let sut: NotificationService;
+  let mocks: ServiceMocks;
+
+  beforeEach(() => {
+    ({ sut, mocks } = newTestService(NotificationService));
+  });
+
+  it('should work', () => {
+    expect(sut).toBeDefined();
+  });
+
+  describe('sendTestEmail', () => {
+    it('should throw error if user could not be found', async () => {
+      await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).rejects.toThrow('User not found');
+    });
+
+    it('should throw error if smtp validation fails', async () => {
+      mocks.user.get.mockResolvedValue(userStub.admin);
+      mocks.email.verifySmtp.mockRejectedValue('');
+
+      await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).rejects.toThrow(
+        'Failed to verify SMTP configuration',
+      );
+    });
+
+    it('should send email to default domain', async () => {
+      mocks.user.get.mockResolvedValue(userStub.admin);
+      mocks.email.verifySmtp.mockResolvedValue(true);
+      mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
+      mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
+
+      await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).resolves.not.toThrow();
+      expect(mocks.email.renderEmail).toHaveBeenCalledWith({
+        template: EmailTemplate.TEST_EMAIL,
+        data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name },
+      });
+      expect(mocks.email.sendEmail).toHaveBeenCalledWith(
+        expect.objectContaining({
+          subject: 'Test email from Immich',
+          smtp: smtpTransport.notifications.smtp.transport,
+        }),
+      );
+    });
+
+    it('should send email to external domain', async () => {
+      mocks.user.get.mockResolvedValue(userStub.admin);
+      mocks.email.verifySmtp.mockResolvedValue(true);
+      mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
+      mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } });
+      mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
+
+      await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).resolves.not.toThrow();
+      expect(mocks.email.renderEmail).toHaveBeenCalledWith({
+        template: EmailTemplate.TEST_EMAIL,
+        data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name },
+      });
+      expect(mocks.email.sendEmail).toHaveBeenCalledWith(
+        expect.objectContaining({
+          subject: 'Test email from Immich',
+          smtp: smtpTransport.notifications.smtp.transport,
+        }),
+      );
+    });
+
+    it('should send email with replyTo', async () => {
+      mocks.user.get.mockResolvedValue(userStub.admin);
+      mocks.email.verifySmtp.mockResolvedValue(true);
+      mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
+      mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
+
+      await expect(
+        sut.sendTestEmail('', { ...smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }),
+      ).resolves.not.toThrow();
+      expect(mocks.email.renderEmail).toHaveBeenCalledWith({
+        template: EmailTemplate.TEST_EMAIL,
+        data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name },
+      });
+      expect(mocks.email.sendEmail).toHaveBeenCalledWith(
+        expect.objectContaining({
+          subject: 'Test email from Immich',
+          smtp: smtpTransport.notifications.smtp.transport,
+          replyTo: 'demo@immich.app',
+        }),
+      );
+    });
+  });
+});
diff --git a/server/src/services/notification-admin.service.ts b/server/src/services/notification-admin.service.ts
new file mode 100644
index 0000000000..bf0d2bba41
--- /dev/null
+++ b/server/src/services/notification-admin.service.ts
@@ -0,0 +1,120 @@
+import { BadRequestException, Injectable } from '@nestjs/common';
+import { AuthDto } from 'src/dtos/auth.dto';
+import { mapNotification, NotificationCreateDto } from 'src/dtos/notification.dto';
+import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
+import { NotificationLevel, NotificationType } from 'src/enum';
+import { EmailTemplate } from 'src/repositories/email.repository';
+import { BaseService } from 'src/services/base.service';
+import { getExternalDomain } from 'src/utils/misc';
+
+@Injectable()
+export class NotificationAdminService extends BaseService {
+  async create(auth: AuthDto, dto: NotificationCreateDto) {
+    const item = await this.notificationRepository.create({
+      userId: dto.userId,
+      type: dto.type ?? NotificationType.Custom,
+      level: dto.level ?? NotificationLevel.Info,
+      title: dto.title,
+      description: dto.description,
+      data: dto.data,
+    });
+
+    return mapNotification(item);
+  }
+
+  async sendTestEmail(id: string, dto: SystemConfigSmtpDto, tempTemplate?: string) {
+    const user = await this.userRepository.get(id, { withDeleted: false });
+    if (!user) {
+      throw new Error('User not found');
+    }
+
+    try {
+      await this.emailRepository.verifySmtp(dto.transport);
+    } catch (error) {
+      throw new BadRequestException('Failed to verify SMTP configuration', { cause: error });
+    }
+
+    const { server } = await this.getConfig({ withCache: false });
+    const { html, text } = await this.emailRepository.renderEmail({
+      template: EmailTemplate.TEST_EMAIL,
+      data: {
+        baseUrl: getExternalDomain(server),
+        displayName: user.name,
+      },
+      customTemplate: tempTemplate!,
+    });
+    const { messageId } = await this.emailRepository.sendEmail({
+      to: user.email,
+      subject: 'Test email from Immich',
+      html,
+      text,
+      from: dto.from,
+      replyTo: dto.replyTo || dto.from,
+      smtp: dto.transport,
+    });
+
+    return { messageId };
+  }
+
+  async getTemplate(name: EmailTemplate, customTemplate: string) {
+    const { server, templates } = await this.getConfig({ withCache: false });
+
+    let templateResponse = '';
+
+    switch (name) {
+      case EmailTemplate.WELCOME: {
+        const { html: _welcomeHtml } = await this.emailRepository.renderEmail({
+          template: EmailTemplate.WELCOME,
+          data: {
+            baseUrl: getExternalDomain(server),
+            displayName: 'John Doe',
+            username: 'john@doe.com',
+            password: 'thisIsAPassword123',
+          },
+          customTemplate: customTemplate || templates.email.welcomeTemplate,
+        });
+
+        templateResponse = _welcomeHtml;
+        break;
+      }
+      case EmailTemplate.ALBUM_UPDATE: {
+        const { html: _updateAlbumHtml } = await this.emailRepository.renderEmail({
+          template: EmailTemplate.ALBUM_UPDATE,
+          data: {
+            baseUrl: getExternalDomain(server),
+            albumId: '1',
+            albumName: 'Favorite Photos',
+            recipientName: 'Jane Doe',
+            cid: undefined,
+          },
+          customTemplate: customTemplate || templates.email.albumInviteTemplate,
+        });
+        templateResponse = _updateAlbumHtml;
+        break;
+      }
+
+      case EmailTemplate.ALBUM_INVITE: {
+        const { html } = await this.emailRepository.renderEmail({
+          template: EmailTemplate.ALBUM_INVITE,
+          data: {
+            baseUrl: getExternalDomain(server),
+            albumId: '1',
+            albumName: "John Doe's Favorites",
+            senderName: 'John Doe',
+            recipientName: 'Jane Doe',
+            cid: undefined,
+          },
+          customTemplate: customTemplate || templates.email.albumInviteTemplate,
+        });
+        templateResponse = html;
+        break;
+      }
+      default: {
+        templateResponse = '';
+        break;
+      }
+    }
+
+    return { name, html: templateResponse };
+  }
+}
diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts
index 5830260753..133eb9e7f6 100644
--- a/server/src/services/notification.service.spec.ts
+++ b/server/src/services/notification.service.spec.ts
@@ -3,7 +3,6 @@ import { defaults, SystemConfig } from 'src/config';
 import { AlbumUser } from 'src/database';
 import { SystemConfigDto } from 'src/dtos/system-config.dto';
 import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
-import { EmailTemplate } from 'src/repositories/email.repository';
 import { NotificationService } from 'src/services/notification.service';
 import { INotifyAlbumUpdateJob } from 'src/types';
 import { albumStub } from 'test/fixtures/album.stub';
@@ -241,82 +240,6 @@ describe(NotificationService.name, () => {
     });
   });
 
-  describe('sendTestEmail', () => {
-    it('should throw error if user could not be found', async () => {
-      await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow('User not found');
-    });
-
-    it('should throw error if smtp validation fails', async () => {
-      mocks.user.get.mockResolvedValue(userStub.admin);
-      mocks.email.verifySmtp.mockRejectedValue('');
-
-      await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow(
-        'Failed to verify SMTP configuration',
-      );
-    });
-
-    it('should send email to default domain', async () => {
-      mocks.user.get.mockResolvedValue(userStub.admin);
-      mocks.email.verifySmtp.mockResolvedValue(true);
-      mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
-      mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
-
-      await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow();
-      expect(mocks.email.renderEmail).toHaveBeenCalledWith({
-        template: EmailTemplate.TEST_EMAIL,
-        data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name },
-      });
-      expect(mocks.email.sendEmail).toHaveBeenCalledWith(
-        expect.objectContaining({
-          subject: 'Test email from Immich',
-          smtp: configs.smtpTransport.notifications.smtp.transport,
-        }),
-      );
-    });
-
-    it('should send email to external domain', async () => {
-      mocks.user.get.mockResolvedValue(userStub.admin);
-      mocks.email.verifySmtp.mockResolvedValue(true);
-      mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
-      mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } });
-      mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
-
-      await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow();
-      expect(mocks.email.renderEmail).toHaveBeenCalledWith({
-        template: EmailTemplate.TEST_EMAIL,
-        data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name },
-      });
-      expect(mocks.email.sendEmail).toHaveBeenCalledWith(
-        expect.objectContaining({
-          subject: 'Test email from Immich',
-          smtp: configs.smtpTransport.notifications.smtp.transport,
-        }),
-      );
-    });
-
-    it('should send email with replyTo', async () => {
-      mocks.user.get.mockResolvedValue(userStub.admin);
-      mocks.email.verifySmtp.mockResolvedValue(true);
-      mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
-      mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
-
-      await expect(
-        sut.sendTestEmail('', { ...configs.smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }),
-      ).resolves.not.toThrow();
-      expect(mocks.email.renderEmail).toHaveBeenCalledWith({
-        template: EmailTemplate.TEST_EMAIL,
-        data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name },
-      });
-      expect(mocks.email.sendEmail).toHaveBeenCalledWith(
-        expect.objectContaining({
-          subject: 'Test email from Immich',
-          smtp: configs.smtpTransport.notifications.smtp.transport,
-          replyTo: 'demo@immich.app',
-        }),
-      );
-    });
-  });
-
   describe('handleUserSignup', () => {
     it('should skip if user could not be found', async () => {
       await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SKIPPED);
diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts
index 573be90f93..be475d1dca 100644
--- a/server/src/services/notification.service.ts
+++ b/server/src/services/notification.service.ts
@@ -1,7 +1,24 @@
 import { BadRequestException, Injectable } from '@nestjs/common';
 import { OnEvent, OnJob } from 'src/decorators';
+import { AuthDto } from 'src/dtos/auth.dto';
+import {
+  mapNotification,
+  NotificationDeleteAllDto,
+  NotificationDto,
+  NotificationSearchDto,
+  NotificationUpdateAllDto,
+  NotificationUpdateDto,
+} from 'src/dtos/notification.dto';
 import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
-import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum';
+import {
+  AssetFileType,
+  JobName,
+  JobStatus,
+  NotificationLevel,
+  NotificationType,
+  Permission,
+  QueueName,
+} from 'src/enum';
 import { EmailTemplate } from 'src/repositories/email.repository';
 import { ArgOf } from 'src/repositories/event.repository';
 import { BaseService } from 'src/services/base.service';
@@ -15,6 +32,80 @@ import { getPreferences } from 'src/utils/preferences';
 export class NotificationService extends BaseService {
   private static albumUpdateEmailDelayMs = 300_000;
 
+  async search(auth: AuthDto, dto: NotificationSearchDto): Promise<NotificationDto[]> {
+    const items = await this.notificationRepository.search(auth.user.id, dto);
+    return items.map((item) => mapNotification(item));
+  }
+
+  async updateAll(auth: AuthDto, dto: NotificationUpdateAllDto) {
+    await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NOTIFICATION_UPDATE });
+    await this.notificationRepository.updateAll(dto.ids, {
+      readAt: dto.readAt,
+    });
+  }
+
+  async deleteAll(auth: AuthDto, dto: NotificationDeleteAllDto) {
+    await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NOTIFICATION_DELETE });
+    await this.notificationRepository.deleteAll(dto.ids);
+  }
+
+  async get(auth: AuthDto, id: string) {
+    await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_READ });
+    const item = await this.notificationRepository.get(id);
+    if (!item) {
+      throw new BadRequestException('Notification not found');
+    }
+    return mapNotification(item);
+  }
+
+  async update(auth: AuthDto, id: string, dto: NotificationUpdateDto) {
+    await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_UPDATE });
+    const item = await this.notificationRepository.update(id, {
+      readAt: dto.readAt,
+    });
+    return mapNotification(item);
+  }
+
+  async delete(auth: AuthDto, id: string) {
+    await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_DELETE });
+    await this.notificationRepository.delete(id);
+  }
+
+  @OnJob({ name: JobName.NOTIFICATIONS_CLEANUP, queue: QueueName.BACKGROUND_TASK })
+  async onNotificationsCleanup() {
+    await this.notificationRepository.cleanup();
+  }
+
+  @OnEvent({ name: 'job.failed' })
+  async onJobFailed({ job, error }: ArgOf<'job.failed'>) {
+    const admin = await this.userRepository.getAdmin();
+    if (!admin) {
+      return;
+    }
+
+    this.logger.error(`Unable to run job handler (${job.name}): ${error}`, error?.stack, JSON.stringify(job.data));
+
+    switch (job.name) {
+      case JobName.BACKUP_DATABASE: {
+        const errorMessage = error instanceof Error ? error.message : error;
+        const item = await this.notificationRepository.create({
+          userId: admin.id,
+          type: NotificationType.JobFailed,
+          level: NotificationLevel.Error,
+          title: 'Job Failed',
+          description: `Job ${[job.name]} failed with error: ${errorMessage}`,
+        });
+
+        this.eventRepository.clientSend('on_notification', admin.id, mapNotification(item));
+        break;
+      }
+
+      default: {
+        return;
+      }
+    }
+  }
+
   @OnEvent({ name: 'config.update' })
   onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) {
     this.eventRepository.clientBroadcast('on_config_update');
diff --git a/server/src/types.ts b/server/src/types.ts
index c5375ae727..ba33e97aad 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -297,6 +297,10 @@ export type JobItem =
   // Metadata Extraction
   | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
   | { name: JobName.METADATA_EXTRACTION; data: IEntityJob }
+
+  // Notifications
+  | { name: JobName.NOTIFICATIONS_CLEANUP; data?: IBaseJob }
+
   // Sidecar Scanning
   | { name: JobName.QUEUE_SIDECAR; data: IBaseJob }
   | { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob }
diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts
index 4e21a9226e..b04d23f114 100644
--- a/server/src/utils/access.ts
+++ b/server/src/utils/access.ts
@@ -221,6 +221,12 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
       return access.person.checkFaceOwnerAccess(auth.user.id, ids);
     }
 
+    case Permission.NOTIFICATION_READ:
+    case Permission.NOTIFICATION_UPDATE:
+    case Permission.NOTIFICATION_DELETE: {
+      return access.notification.checkOwnerAccess(auth.user.id, ids);
+    }
+
     case Permission.TAG_ASSET:
     case Permission.TAG_READ:
     case Permission.TAG_UPDATE:
diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts
index 671a8a50ca..3684837baa 100644
--- a/server/test/medium.factory.ts
+++ b/server/test/medium.factory.ts
@@ -13,9 +13,11 @@ import { AssetRepository } from 'src/repositories/asset.repository';
 import { ConfigRepository } from 'src/repositories/config.repository';
 import { CryptoRepository } from 'src/repositories/crypto.repository';
 import { DatabaseRepository } from 'src/repositories/database.repository';
+import { EmailRepository } from 'src/repositories/email.repository';
 import { JobRepository } from 'src/repositories/job.repository';
 import { LoggingRepository } from 'src/repositories/logging.repository';
 import { MemoryRepository } from 'src/repositories/memory.repository';
+import { NotificationRepository } from 'src/repositories/notification.repository';
 import { PartnerRepository } from 'src/repositories/partner.repository';
 import { PersonRepository } from 'src/repositories/person.repository';
 import { SearchRepository } from 'src/repositories/search.repository';
@@ -42,10 +44,12 @@ type RepositoriesTypes = {
   config: ConfigRepository;
   crypto: CryptoRepository;
   database: DatabaseRepository;
+  email: EmailRepository;
   job: JobRepository;
   user: UserRepository;
   logger: LoggingRepository;
   memory: MemoryRepository;
+  notification: NotificationRepository;
   partner: PartnerRepository;
   person: PersonRepository;
   search: SearchRepository;
@@ -142,6 +146,11 @@ export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kys
       return new DatabaseRepository(db, new LoggingRepository(undefined, configRepo), configRepo);
     }
 
+    case 'email': {
+      const logger = new LoggingRepository(undefined, new ConfigRepository());
+      return new EmailRepository(logger);
+    }
+
     case 'logger': {
       const configMock = { getEnv: () => ({ noColor: false }) };
       return new LoggingRepository(undefined, configMock as ConfigRepository);
@@ -151,6 +160,10 @@ export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kys
       return new MemoryRepository(db);
     }
 
+    case 'notification': {
+      return new NotificationRepository(db);
+    }
+
     case 'partner': {
       return new PartnerRepository(db);
     }
@@ -221,6 +234,10 @@ const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
       });
     }
 
+    case 'email': {
+      return automock(EmailRepository, { args: [{ setContext: () => {} }] });
+    }
+
     case 'job': {
       return automock(JobRepository, { args: [undefined, undefined, undefined, { setContext: () => {} }] });
     }
@@ -234,6 +251,10 @@ const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
       return automock(MemoryRepository);
     }
 
+    case 'notification': {
+      return automock(NotificationRepository);
+    }
+
     case 'partner': {
       return automock(PartnerRepository);
     }
@@ -284,7 +305,7 @@ export const asDeps = (repositories: ServiceOverrides) => {
     repositories.crypto || getRepositoryMock('crypto'),
     repositories.database || getRepositoryMock('database'),
     repositories.downloadRepository,
-    repositories.email,
+    repositories.email || getRepositoryMock('email'),
     repositories.event,
     repositories.job || getRepositoryMock('job'),
     repositories.library,
@@ -294,6 +315,7 @@ export const asDeps = (repositories: ServiceOverrides) => {
     repositories.memory || getRepositoryMock('memory'),
     repositories.metadata,
     repositories.move,
+    repositories.notification || getRepositoryMock('notification'),
     repositories.oauth,
     repositories.partner || getRepositoryMock('partner'),
     repositories.person || getRepositoryMock('person'),
diff --git a/server/test/medium/specs/controllers/notification.controller.spec.ts b/server/test/medium/specs/controllers/notification.controller.spec.ts
new file mode 100644
index 0000000000..f4a0ec82d5
--- /dev/null
+++ b/server/test/medium/specs/controllers/notification.controller.spec.ts
@@ -0,0 +1,86 @@
+import { NotificationController } from 'src/controllers/notification.controller';
+import { AuthService } from 'src/services/auth.service';
+import { NotificationService } from 'src/services/notification.service';
+import request from 'supertest';
+import { errorDto } from 'test/medium/responses';
+import { createControllerTestApp, TestControllerApp } from 'test/medium/utils';
+import { factory } from 'test/small.factory';
+
+describe(NotificationController.name, () => {
+  let realApp: TestControllerApp;
+  let mockApp: TestControllerApp;
+
+  beforeEach(async () => {
+    realApp = await createControllerTestApp({ authType: 'real' });
+    mockApp = await createControllerTestApp({ authType: 'mock' });
+  });
+
+  describe('GET /notifications', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(realApp.getHttpServer()).get('/notifications');
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+
+    it('should call the service with an auth dto', async () => {
+      const auth = factory.auth({ user: factory.user() });
+      mockApp.getMockedService(AuthService).authenticate.mockResolvedValue(auth);
+      const service = mockApp.getMockedService(NotificationService);
+
+      const { status } = await request(mockApp.getHttpServer())
+        .get('/notifications')
+        .set('Authorization', `Bearer token`);
+
+      expect(status).toBe(200);
+      expect(service.search).toHaveBeenCalledWith(auth, {});
+    });
+
+    it(`should reject an invalid notification level`, async () => {
+      const auth = factory.auth({ user: factory.user() });
+      mockApp.getMockedService(AuthService).authenticate.mockResolvedValue(auth);
+      const service = mockApp.getMockedService(NotificationService);
+
+      const { status, body } = await request(mockApp.getHttpServer())
+        .get(`/notifications`)
+        .query({ level: 'invalid' })
+        .set('Authorization', `Bearer token`);
+
+      expect(status).toBe(400);
+      expect(body).toEqual(errorDto.badRequest([expect.stringContaining('level must be one of the following values')]));
+      expect(service.search).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('PUT /notifications', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(realApp.getHttpServer())
+        .put(`/notifications`)
+        .send({ ids: [], readAt: new Date().toISOString() });
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+  });
+
+  describe('GET /notifications/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(realApp.getHttpServer()).get(`/notifications/${factory.uuid()}`);
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+  });
+
+  describe('PUT /notifications/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(realApp.getHttpServer())
+        .put(`/notifications/${factory.uuid()}`)
+        .send({ readAt: factory.date() });
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+  });
+
+  afterAll(async () => {
+    await realApp.close();
+    await mockApp.close();
+  });
+});
diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts
index ec5115b839..5b98b95e27 100644
--- a/server/test/repositories/access.repository.mock.ts
+++ b/server/test/repositories/access.repository.mock.ts
@@ -37,6 +37,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
       checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
     },
 
+    notification: {
+      checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
+    },
+
     person: {
       checkFaceOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
       checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts
index 919cdd4b1c..d2742f7f80 100644
--- a/server/test/small.factory.ts
+++ b/server/test/small.factory.ts
@@ -314,4 +314,5 @@ export const factory = {
     sidecarWrite: assetSidecarWriteFactory,
   },
   uuid: newUuid,
+  date: newDate,
 };
diff --git a/server/test/utils.ts b/server/test/utils.ts
index c7c29d310e..2c444f491e 100644
--- a/server/test/utils.ts
+++ b/server/test/utils.ts
@@ -29,6 +29,7 @@ import { MediaRepository } from 'src/repositories/media.repository';
 import { MemoryRepository } from 'src/repositories/memory.repository';
 import { MetadataRepository } from 'src/repositories/metadata.repository';
 import { MoveRepository } from 'src/repositories/move.repository';
+import { NotificationRepository } from 'src/repositories/notification.repository';
 import { OAuthRepository } from 'src/repositories/oauth.repository';
 import { PartnerRepository } from 'src/repositories/partner.repository';
 import { PersonRepository } from 'src/repositories/person.repository';
@@ -135,6 +136,7 @@ export type ServiceOverrides = {
   memory: MemoryRepository;
   metadata: MetadataRepository;
   move: MoveRepository;
+  notification: NotificationRepository;
   oauth: OAuthRepository;
   partner: PartnerRepository;
   person: PersonRepository;
@@ -202,6 +204,7 @@ export const newTestService = <T extends BaseService>(
     memory: automock(MemoryRepository),
     metadata: newMetadataRepositoryMock(),
     move: automock(MoveRepository, { strict: false }),
+    notification: automock(NotificationRepository),
     oauth: automock(OAuthRepository, { args: [loggerMock] }),
     partner: automock(PartnerRepository, { strict: false }),
     person: newPersonRepositoryMock(),
@@ -250,6 +253,7 @@ export const newTestService = <T extends BaseService>(
     overrides.memory || (mocks.memory as As<MemoryRepository>),
     overrides.metadata || (mocks.metadata as As<MetadataRepository>),
     overrides.move || (mocks.move as As<MoveRepository>),
+    overrides.notification || (mocks.notification as As<NotificationRepository>),
     overrides.oauth || (mocks.oauth as As<OAuthRepository>),
     overrides.partner || (mocks.partner as As<PartnerRepository>),
     overrides.person || (mocks.person as As<PersonRepository>),
diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
index e91db5cc3a..2ebe4febab 100644
--- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
+++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
@@ -8,6 +8,7 @@
   import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
   import HelpAndFeedbackModal from '$lib/components/shared-components/help-and-feedback-modal.svelte';
   import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
+  import NotificationPanel from '$lib/components/shared-components/navigation-bar/notification-panel.svelte';
   import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
   import { AppRoute } from '$lib/constants';
   import { authManager } from '$lib/stores/auth-manager.svelte';
@@ -18,13 +19,14 @@
   import { userInteraction } from '$lib/stores/user.svelte';
   import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
   import { Button, IconButton } from '@immich/ui';
-  import { mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
+  import { mdiBellBadge, mdiBellOutline, mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
   import { onMount } from 'svelte';
   import { t } from 'svelte-i18n';
   import { fade } from 'svelte/transition';
   import ThemeButton from '../theme-button.svelte';
   import UserAvatar from '../user-avatar.svelte';
   import AccountInfoPanel from './account-info-panel.svelte';
+  import { notificationManager } from '$lib/stores/notification-manager.svelte';
 
   interface Props {
     showUploadButton?: boolean;
@@ -36,7 +38,9 @@
   let shouldShowAccountInfo = $state(false);
   let shouldShowAccountInfoPanel = $state(false);
   let shouldShowHelpPanel = $state(false);
+  let shouldShowNotificationPanel = $state(false);
   let innerWidth: number = $state(0);
+  const hasUnreadNotifications = $derived(notificationManager.notifications.length > 0);
 
   let info: ServerAboutResponseDto | undefined = $state();
 
@@ -146,6 +150,27 @@
           />
         </div>
 
+        <div
+          use:clickOutside={{
+            onOutclick: () => (shouldShowNotificationPanel = false),
+            onEscape: () => (shouldShowNotificationPanel = false),
+          }}
+        >
+          <IconButton
+            shape="round"
+            color={hasUnreadNotifications ? 'primary' : 'secondary'}
+            variant="ghost"
+            size="medium"
+            icon={hasUnreadNotifications ? mdiBellBadge : mdiBellOutline}
+            onclick={() => (shouldShowNotificationPanel = !shouldShowNotificationPanel)}
+            aria-label={$t('notifications')}
+          />
+
+          {#if shouldShowNotificationPanel}
+            <NotificationPanel />
+          {/if}
+        </div>
+
         <div
           use:clickOutside={{
             onOutclick: () => (shouldShowAccountInfoPanel = false),
diff --git a/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte b/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte
new file mode 100644
index 0000000000..0d05e2d6d7
--- /dev/null
+++ b/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte
@@ -0,0 +1,114 @@
+<script lang="ts">
+  import { NotificationLevel, NotificationType, type NotificationDto } from '@immich/sdk';
+  import { IconButton, Stack, Text } from '@immich/ui';
+  import { mdiBackupRestore, mdiInformationOutline, mdiMessageBadgeOutline, mdiSync } from '@mdi/js';
+  import { DateTime } from 'luxon';
+
+  interface Props {
+    notification: NotificationDto;
+    onclick: (id: string) => void;
+  }
+
+  let { notification, onclick }: Props = $props();
+
+  const getAlertColor = (level: NotificationLevel) => {
+    switch (level) {
+      case NotificationLevel.Error: {
+        return 'danger';
+      }
+      case NotificationLevel.Warning: {
+        return 'warning';
+      }
+      case NotificationLevel.Info: {
+        return 'primary';
+      }
+      case NotificationLevel.Success: {
+        return 'success';
+      }
+      default: {
+        return 'primary';
+      }
+    }
+  };
+
+  const getIconBgColor = (level: NotificationLevel) => {
+    switch (level) {
+      case NotificationLevel.Error: {
+        return 'bg-red-500 dark:bg-red-300 dark:hover:bg-red-200';
+      }
+      case NotificationLevel.Warning: {
+        return 'bg-amber-500 dark:bg-amber-200 dark:hover:bg-amber-200';
+      }
+      case NotificationLevel.Info: {
+        return 'bg-blue-500 dark:bg-blue-200 dark:hover:bg-blue-200';
+      }
+      case NotificationLevel.Success: {
+        return 'bg-green-500 dark:bg-green-200 dark:hover:bg-green-200';
+      }
+    }
+  };
+
+  const getIconType = (type: NotificationType) => {
+    switch (type) {
+      case NotificationType.BackupFailed: {
+        return mdiBackupRestore;
+      }
+      case NotificationType.JobFailed: {
+        return mdiSync;
+      }
+      case NotificationType.SystemMessage: {
+        return mdiMessageBadgeOutline;
+      }
+      case NotificationType.Custom: {
+        return mdiInformationOutline;
+      }
+    }
+  };
+
+  const formatRelativeTime = (dateString: string): string => {
+    try {
+      const date = DateTime.fromISO(dateString);
+      if (!date.isValid) {
+        return dateString; // Return original string if parsing fails
+      }
+      // Use Luxon's toRelative with the current locale
+      return date.setLocale('en').toRelative() || dateString;
+    } catch (error) {
+      console.error('Error formatting relative time:', error);
+      return dateString; // Fallback to original string on error
+    }
+  };
+</script>
+
+<button
+  class="min-h-[80px] p-2 py-3 hover:bg-immich-primary/10 dark:hover:bg-immich-dark-primary/10 border-b border-gray-200 dark:border-immich-dark-gray w-full"
+  type="button"
+  onclick={() => onclick(notification.id)}
+  title={notification.createdAt}
+>
+  <div class="grid grid-cols-[56px_1fr_32px] items-center gap-2">
+    <div class="flex place-items-center place-content-center">
+      <IconButton
+        icon={getIconType(notification.type)}
+        color={getAlertColor(notification.level)}
+        aria-label={notification.title}
+        shape="round"
+        class={getIconBgColor(notification.level)}
+        size="small"
+      ></IconButton>
+    </div>
+
+    <Stack class="text-left" gap={1}>
+      <Text size="tiny" class="uppercase text-black dark:text-white font-semibold">{notification.title}</Text>
+      {#if notification.description}
+        <Text class="overflow-hidden text-gray-600 dark:text-gray-300">{notification.description}</Text>
+      {/if}
+
+      <Text size="tiny" color="muted">{formatRelativeTime(notification.createdAt)}</Text>
+    </Stack>
+
+    {#if !notification.readAt}
+      <div class="w-2 h-2 rounded-full bg-primary text-right justify-self-center"></div>
+    {/if}
+  </div>
+</button>
diff --git a/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte
new file mode 100644
index 0000000000..be9fcd2a44
--- /dev/null
+++ b/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte
@@ -0,0 +1,82 @@
+<script lang="ts">
+  import { focusTrap } from '$lib/actions/focus-trap';
+  import Icon from '$lib/components/elements/icon.svelte';
+  import NotificationItem from '$lib/components/shared-components/navigation-bar/notification-item.svelte';
+  import {
+    notificationController,
+    NotificationType as WebNotificationType,
+  } from '$lib/components/shared-components/notification/notification';
+
+  import { notificationManager } from '$lib/stores/notification-manager.svelte';
+  import { handleError } from '$lib/utils/handle-error';
+  import { Button, Scrollable, Stack, Text } from '@immich/ui';
+  import { mdiBellOutline, mdiCheckAll } from '@mdi/js';
+  import { t } from 'svelte-i18n';
+  import { fade } from 'svelte/transition';
+  import { flip } from 'svelte/animate';
+
+  const noUnreadNotifications = $derived(notificationManager.notifications.length === 0);
+
+  const markAsRead = async (id: string) => {
+    try {
+      await notificationManager.markAsRead(id);
+    } catch (error) {
+      handleError(error, $t('errors.failed_to_update_notification_status'));
+    }
+  };
+
+  const markAllAsRead = async () => {
+    try {
+      await notificationManager.markAllAsRead();
+      notificationController.show({ message: $t('marked_all_as_read'), type: WebNotificationType.Info });
+    } catch (error) {
+      handleError(error, $t('errors.failed_to_update_notification_status'));
+    }
+  };
+</script>
+
+<div
+  in:fade={{ duration: 100 }}
+  out:fade={{ duration: 100 }}
+  id="notification-panel"
+  class="absolute right-[25px] top-[70px] z-[100] w-[min(360px,100vw-50px)] rounded-3xl bg-gray-100 border border-gray-200 shadow-lg dark:border dark:border-immich-dark-gray dark:bg-immich-dark-gray text-light"
+  use:focusTrap
+>
+  <Stack class="max-h-[500px]">
+    <div class="flex justify-between items-center mt-4 mx-4">
+      <Text size="medium" color="secondary" class="font-semibold">{$t('notifications')}</Text>
+      <div>
+        <Button
+          variant="ghost"
+          disabled={noUnreadNotifications}
+          leadingIcon={mdiCheckAll}
+          size="small"
+          color="primary"
+          onclick={() => markAllAsRead()}>{$t('mark_all_as_read')}</Button
+        >
+      </div>
+    </div>
+
+    <hr />
+
+    {#if noUnreadNotifications}
+      <Stack
+        class="py-12 flex flex-col place-items-center place-content-center text-gray-700 dark:text-gray-300"
+        gap={1}
+      >
+        <Icon path={mdiBellOutline} size={20}></Icon>
+        <Text>{$t('no_notifications')}</Text>
+      </Stack>
+    {:else}
+      <Scrollable class="pb-6">
+        <Stack gap={0}>
+          {#each notificationManager.notifications as notification (notification.id)}
+            <div animate:flip={{ duration: 400 }}>
+              <NotificationItem {notification} onclick={(id) => markAsRead(id)} />
+            </div>
+          {/each}
+        </Stack>
+      </Scrollable>
+    {/if}
+  </Stack>
+</div>
diff --git a/web/src/lib/stores/notification-manager.svelte.ts b/web/src/lib/stores/notification-manager.svelte.ts
new file mode 100644
index 0000000000..c06400fd16
--- /dev/null
+++ b/web/src/lib/stores/notification-manager.svelte.ts
@@ -0,0 +1,38 @@
+import { eventManager } from '$lib/stores/event-manager.svelte';
+import { getNotifications, updateNotification, updateNotifications, type NotificationDto } from '@immich/sdk';
+
+class NotificationStore {
+  notifications = $state<NotificationDto[]>([]);
+
+  constructor() {
+    // TODO replace this with an `auth.login` event
+    this.refresh().catch(() => {});
+
+    eventManager.on('auth.logout', () => this.clear());
+  }
+
+  get hasUnread() {
+    return this.notifications.length > 0;
+  }
+
+  refresh = async () => {
+    this.notifications = await getNotifications({ unread: true });
+  };
+
+  markAsRead = async (id: string) => {
+    this.notifications = this.notifications.filter((notification) => notification.id !== id);
+    await updateNotification({ id, notificationUpdateDto: { readAt: new Date().toISOString() } });
+  };
+
+  markAllAsRead = async () => {
+    const ids = this.notifications.map(({ id }) => id);
+    this.notifications = [];
+    await updateNotifications({ notificationUpdateAllDto: { ids, readAt: new Date().toISOString() } });
+  };
+
+  clear = () => {
+    this.notifications = [];
+  };
+}
+
+export const notificationManager = new NotificationStore();
diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts
index 90228a5cbd..ccfcfb7805 100644
--- a/web/src/lib/stores/websocket.ts
+++ b/web/src/lib/stores/websocket.ts
@@ -1,6 +1,7 @@
 import { authManager } from '$lib/stores/auth-manager.svelte';
+import { notificationManager } from '$lib/stores/notification-manager.svelte';
 import { createEventEmitter } from '$lib/utils/eventemitter';
-import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk';
+import { type AssetResponseDto, type NotificationDto, type ServerVersionResponseDto } from '@immich/sdk';
 import { io, type Socket } from 'socket.io-client';
 import { get, writable } from 'svelte/store';
 import { user } from './user.store';
@@ -26,6 +27,7 @@ export interface Events {
   on_config_update: () => void;
   on_new_release: (newRelase: ReleaseEvent) => void;
   on_session_delete: (sessionId: string) => void;
+  on_notification: (notification: NotificationDto) => void;
 }
 
 const websocket: Socket<Events> = io({
@@ -50,6 +52,7 @@ websocket
   .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion))
   .on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion))
   .on('on_session_delete', () => authManager.logout())
+  .on('on_notification', () => notificationManager.refresh())
   .on('connect_error', (e) => console.log('Websocket Connect Error', e));
 
 export const openWebsocketConnection = () => {
diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte
index aa756ac2e8..1dcb91f996 100644
--- a/web/src/routes/auth/login/+page.svelte
+++ b/web/src/routes/auth/login/+page.svelte
@@ -10,6 +10,7 @@
   import { onMount } from 'svelte';
   import { t } from 'svelte-i18n';
   import type { PageData } from './$types';
+  import { notificationManager } from '$lib/stores/notification-manager.svelte';
 
   interface Props {
     data: PageData;
@@ -24,7 +25,10 @@
   let loading = $state(false);
   let oauthLoading = $state(true);
 
-  const onSuccess = async () => await goto(AppRoute.PHOTOS, { invalidateAll: true });
+  const onSuccess = async () => {
+    await notificationManager.refresh();
+    await goto(AppRoute.PHOTOS, { invalidateAll: true });
+  };
   const onFirstLogin = async () => await goto(AppRoute.AUTH_CHANGE_PASSWORD);
   const onOnboarding = async () => await goto(AppRoute.AUTH_ONBOARDING);