diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index a041d98419c5c..4acc0664fb4d4 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -250,10 +250,18 @@ describe('/admin/users', () => { .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toEqual({ avatar: { color: 'orange' }, memories: { enabled: false } }); + expect(body).toEqual({ + avatar: { color: 'orange' }, + memories: { enabled: false }, + emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true }, + }); const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); - expect(after).toEqual({ avatar: { color: 'orange' }, memories: { enabled: false } }); + expect(after).toEqual({ + avatar: { color: 'orange' }, + memories: { enabled: false }, + emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true }, + }); }); }); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 5e543c026bf8d..93ea6a551c1a5 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -294,6 +294,8 @@ Class | Method | HTTP request | Description - [DownloadResponseDto](doc//DownloadResponseDto.md) - [DuplicateDetectionConfig](doc//DuplicateDetectionConfig.md) - [DuplicateResponseDto](doc//DuplicateResponseDto.md) + - [EmailNotificationsResponse](doc//EmailNotificationsResponse.md) + - [EmailNotificationsUpdate](doc//EmailNotificationsUpdate.md) - [EntityType](doc//EntityType.md) - [ExifResponseDto](doc//ExifResponseDto.md) - [FaceDto](doc//FaceDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 4c4c75f1876d9..17602fafafc7b 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -121,6 +121,8 @@ part 'model/download_info_dto.dart'; part 'model/download_response_dto.dart'; part 'model/duplicate_detection_config.dart'; part 'model/duplicate_response_dto.dart'; +part 'model/email_notifications_response.dart'; +part 'model/email_notifications_update.dart'; part 'model/entity_type.dart'; part 'model/exif_response_dto.dart'; part 'model/face_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index fd62d63ccbe9c..94187719a83e4 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -306,6 +306,10 @@ class ApiClient { return DuplicateDetectionConfig.fromJson(value); case 'DuplicateResponseDto': return DuplicateResponseDto.fromJson(value); + case 'EmailNotificationsResponse': + return EmailNotificationsResponse.fromJson(value); + case 'EmailNotificationsUpdate': + return EmailNotificationsUpdate.fromJson(value); case 'EntityType': return EntityTypeTypeTransformer().decode(value); case 'ExifResponseDto': diff --git a/mobile/openapi/lib/model/email_notifications_response.dart b/mobile/openapi/lib/model/email_notifications_response.dart new file mode 100644 index 0000000000000..cef92957c6d78 --- /dev/null +++ b/mobile/openapi/lib/model/email_notifications_response.dart @@ -0,0 +1,114 @@ +// +// 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 EmailNotificationsResponse { + /// Returns a new [EmailNotificationsResponse] instance. + EmailNotificationsResponse({ + required this.albumInvite, + required this.albumUpdate, + required this.enabled, + }); + + bool albumInvite; + + bool albumUpdate; + + bool enabled; + + @override + bool operator ==(Object other) => identical(this, other) || other is EmailNotificationsResponse && + other.albumInvite == albumInvite && + other.albumUpdate == albumUpdate && + other.enabled == enabled; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albumInvite.hashCode) + + (albumUpdate.hashCode) + + (enabled.hashCode); + + @override + String toString() => 'EmailNotificationsResponse[albumInvite=$albumInvite, albumUpdate=$albumUpdate, enabled=$enabled]'; + + Map toJson() { + final json = {}; + json[r'albumInvite'] = this.albumInvite; + json[r'albumUpdate'] = this.albumUpdate; + json[r'enabled'] = this.enabled; + return json; + } + + /// Returns a new [EmailNotificationsResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static EmailNotificationsResponse? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return EmailNotificationsResponse( + albumInvite: mapValueOfType(json, r'albumInvite')!, + albumUpdate: mapValueOfType(json, r'albumUpdate')!, + enabled: mapValueOfType(json, r'enabled')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = EmailNotificationsResponse.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = EmailNotificationsResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of EmailNotificationsResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = EmailNotificationsResponse.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albumInvite', + 'albumUpdate', + 'enabled', + }; +} + diff --git a/mobile/openapi/lib/model/email_notifications_update.dart b/mobile/openapi/lib/model/email_notifications_update.dart new file mode 100644 index 0000000000000..dcd1ec432206d --- /dev/null +++ b/mobile/openapi/lib/model/email_notifications_update.dart @@ -0,0 +1,141 @@ +// +// 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 EmailNotificationsUpdate { + /// Returns a new [EmailNotificationsUpdate] instance. + EmailNotificationsUpdate({ + this.albumInvite, + this.albumUpdate, + this.enabled, + }); + + /// + /// 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. + /// + bool? albumInvite; + + /// + /// 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. + /// + bool? albumUpdate; + + /// + /// 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. + /// + bool? enabled; + + @override + bool operator ==(Object other) => identical(this, other) || other is EmailNotificationsUpdate && + other.albumInvite == albumInvite && + other.albumUpdate == albumUpdate && + other.enabled == enabled; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albumInvite == null ? 0 : albumInvite!.hashCode) + + (albumUpdate == null ? 0 : albumUpdate!.hashCode) + + (enabled == null ? 0 : enabled!.hashCode); + + @override + String toString() => 'EmailNotificationsUpdate[albumInvite=$albumInvite, albumUpdate=$albumUpdate, enabled=$enabled]'; + + Map toJson() { + final json = {}; + if (this.albumInvite != null) { + json[r'albumInvite'] = this.albumInvite; + } else { + // json[r'albumInvite'] = null; + } + if (this.albumUpdate != null) { + json[r'albumUpdate'] = this.albumUpdate; + } else { + // json[r'albumUpdate'] = null; + } + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + return json; + } + + /// Returns a new [EmailNotificationsUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static EmailNotificationsUpdate? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return EmailNotificationsUpdate( + albumInvite: mapValueOfType(json, r'albumInvite'), + albumUpdate: mapValueOfType(json, r'albumUpdate'), + enabled: mapValueOfType(json, r'enabled'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = EmailNotificationsUpdate.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = EmailNotificationsUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of EmailNotificationsUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = EmailNotificationsUpdate.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index 673f5bfaf8564..4db7104325f17 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -14,30 +14,36 @@ class UserPreferencesResponseDto { /// Returns a new [UserPreferencesResponseDto] instance. UserPreferencesResponseDto({ required this.avatar, + required this.emailNotifications, required this.memories, }); AvatarResponse avatar; + EmailNotificationsResponse emailNotifications; + MemoryResponse memories; @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto && other.avatar == avatar && + other.emailNotifications == emailNotifications && other.memories == memories; @override int get hashCode => // ignore: unnecessary_parenthesis (avatar.hashCode) + + (emailNotifications.hashCode) + (memories.hashCode); @override - String toString() => 'UserPreferencesResponseDto[avatar=$avatar, memories=$memories]'; + String toString() => 'UserPreferencesResponseDto[avatar=$avatar, emailNotifications=$emailNotifications, memories=$memories]'; Map toJson() { final json = {}; json[r'avatar'] = this.avatar; + json[r'emailNotifications'] = this.emailNotifications; json[r'memories'] = this.memories; return json; } @@ -51,6 +57,7 @@ class UserPreferencesResponseDto { return UserPreferencesResponseDto( avatar: AvatarResponse.fromJson(json[r'avatar'])!, + emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!, memories: MemoryResponse.fromJson(json[r'memories'])!, ); } @@ -100,6 +107,7 @@ class UserPreferencesResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'avatar', + 'emailNotifications', 'memories', }; } diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 887293931c282..21da7c7bac640 100644 --- a/mobile/openapi/lib/model/user_preferences_update_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_update_dto.dart @@ -14,6 +14,7 @@ class UserPreferencesUpdateDto { /// Returns a new [UserPreferencesUpdateDto] instance. UserPreferencesUpdateDto({ this.avatar, + this.emailNotifications, this.memories, }); @@ -25,6 +26,14 @@ class UserPreferencesUpdateDto { /// AvatarUpdate? avatar; + /// + /// 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. + /// + EmailNotificationsUpdate? emailNotifications; + /// /// 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 @@ -36,16 +45,18 @@ class UserPreferencesUpdateDto { @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesUpdateDto && other.avatar == avatar && + other.emailNotifications == emailNotifications && other.memories == memories; @override int get hashCode => // ignore: unnecessary_parenthesis (avatar == null ? 0 : avatar!.hashCode) + + (emailNotifications == null ? 0 : emailNotifications!.hashCode) + (memories == null ? 0 : memories!.hashCode); @override - String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, memories=$memories]'; + String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, emailNotifications=$emailNotifications, memories=$memories]'; Map toJson() { final json = {}; @@ -54,6 +65,11 @@ class UserPreferencesUpdateDto { } else { // json[r'avatar'] = null; } + if (this.emailNotifications != null) { + json[r'emailNotifications'] = this.emailNotifications; + } else { + // json[r'emailNotifications'] = null; + } if (this.memories != null) { json[r'memories'] = this.memories; } else { @@ -71,6 +87,7 @@ class UserPreferencesUpdateDto { return UserPreferencesUpdateDto( avatar: AvatarUpdate.fromJson(json[r'avatar']), + emailNotifications: EmailNotificationsUpdate.fromJson(json[r'emailNotifications']), memories: MemoryUpdate.fromJson(json[r'memories']), ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5ffd86a07c91a..eeef262ea0f1b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8152,6 +8152,39 @@ ], "type": "object" }, + "EmailNotificationsResponse": { + "properties": { + "albumInvite": { + "type": "boolean" + }, + "albumUpdate": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "albumInvite", + "albumUpdate", + "enabled" + ], + "type": "object" + }, + "EmailNotificationsUpdate": { + "properties": { + "albumInvite": { + "type": "boolean" + }, + "albumUpdate": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + } + }, + "type": "object" + }, "EntityType": { "enum": [ "ASSET", @@ -11205,12 +11238,16 @@ "avatar": { "$ref": "#/components/schemas/AvatarResponse" }, + "emailNotifications": { + "$ref": "#/components/schemas/EmailNotificationsResponse" + }, "memories": { "$ref": "#/components/schemas/MemoryResponse" } }, "required": [ "avatar", + "emailNotifications", "memories" ], "type": "object" @@ -11220,6 +11257,9 @@ "avatar": { "$ref": "#/components/schemas/AvatarUpdate" }, + "emailNotifications": { + "$ref": "#/components/schemas/EmailNotificationsUpdate" + }, "memories": { "$ref": "#/components/schemas/MemoryUpdate" } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index b533a8f3afc4e..c835ff1902e6d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -78,21 +78,33 @@ export type UserAdminUpdateDto = { export type AvatarResponse = { color: UserAvatarColor; }; +export type EmailNotificationsResponse = { + albumInvite: boolean; + albumUpdate: boolean; + enabled: boolean; +}; export type MemoryResponse = { enabled: boolean; }; export type UserPreferencesResponseDto = { avatar: AvatarResponse; + emailNotifications: EmailNotificationsResponse; memories: MemoryResponse; }; export type AvatarUpdate = { color?: UserAvatarColor; }; +export type EmailNotificationsUpdate = { + albumInvite?: boolean; + albumUpdate?: boolean; + enabled?: boolean; +}; export type MemoryUpdate = { enabled?: boolean; }; export type UserPreferencesUpdateDto = { avatar?: AvatarUpdate; + emailNotifications?: EmailNotificationsUpdate; memories?: MemoryUpdate; }; export type AlbumUserResponseDto = { diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 2dd9492d07423..64120be22be55 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -16,6 +16,17 @@ class MemoryUpdate { enabled?: boolean; } +class EmailNotificationsUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateBoolean({ optional: true }) + albumInvite?: boolean; + + @ValidateBoolean({ optional: true }) + albumUpdate?: boolean; +} + export class UserPreferencesUpdateDto { @Optional() @ValidateNested() @@ -26,6 +37,11 @@ export class UserPreferencesUpdateDto { @ValidateNested() @Type(() => MemoryUpdate) memories?: MemoryUpdate; + + @Optional() + @ValidateNested() + @Type(() => EmailNotificationsUpdate) + emailNotifications?: EmailNotificationsUpdate; } class AvatarResponse { @@ -37,9 +53,16 @@ class MemoryResponse { enabled!: boolean; } +class EmailNotificationsResponse { + enabled!: boolean; + albumInvite!: boolean; + albumUpdate!: boolean; +} + export class UserPreferencesResponseDto implements UserPreferences { memories!: MemoryResponse; avatar!: AvatarResponse; + emailNotifications!: EmailNotificationsResponse; } export const mapPreferences = (preferences: UserPreferences): UserPreferencesResponseDto => { diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index 26715e05e312d..b109455310d70 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -36,6 +36,11 @@ export interface UserPreferences { avatar: { color: UserAvatarColor; }; + emailNotifications: { + enabled: boolean; + albumInvite: boolean; + albumUpdate: boolean; + }; } export const getDefaultPreferences = (user: { email: string }): UserPreferences => { @@ -51,6 +56,11 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences avatar: { color: values[randomIndex], }, + emailNotifications: { + enabled: true, + albumInvite: true, + albumUpdate: true, + }, }; }; diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index ddd8d61e3dff5..8efc6a6c33872 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -19,6 +19,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailImageAttachment, EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { getPreferences } from 'src/utils/preferences'; @Injectable() export class NotificationService { @@ -95,6 +96,12 @@ export class NotificationService { return JobStatus.SKIPPED; } + const { emailNotifications } = getPreferences(recipient); + + if (!emailNotifications.enabled || !emailNotifications.albumInvite) { + return JobStatus.SKIPPED; + } + const attachment = await this.getAlbumThumbnailAttachment(album); const { server } = await this.configCore.getConfig(); @@ -142,6 +149,12 @@ export class NotificationService { const { server } = await this.configCore.getConfig(); for (const recipient of recipients) { + const { emailNotifications } = getPreferences(recipient); + + if (!emailNotifications.enabled || !emailNotifications.albumUpdate) { + continue; + } + const { html, text } = this.notificationRepository.renderEmail({ template: EmailTemplate.ALBUM_UPDATE, data: { diff --git a/web/src/lib/components/user-settings-page/notifications-settings.svelte b/web/src/lib/components/user-settings-page/notifications-settings.svelte new file mode 100644 index 0000000000000..3005a08a83f7f --- /dev/null +++ b/web/src/lib/components/user-settings-page/notifications-settings.svelte @@ -0,0 +1,75 @@ + + +
+
+
+
+
+ +
+
+ +
+
+ +
+ +
+ +
+
+
+
+
diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index f88ee588729a4..95a792eb6ef57 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -15,6 +15,7 @@ import PartnerSettings from './partner-settings.svelte'; import UserAPIKeyList from './user-api-key-list.svelte'; import UserProfileSettings from './user-profile-settings.svelte'; + import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte'; export let keys: ApiKeyResponseDto[] = []; export let sessions: SessionResponseDto[] = []; @@ -45,6 +46,10 @@ + + + + {#if $featureFlags.loaded && $featureFlags.oauth}