diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index a041d9841..4acc0664f 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 5e543c026..93ea6a551 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 4c4c75f18..17602fafa 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index fd62d63cc..94187719a 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ 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 000000000..cef92957c Binary files /dev/null and b/mobile/openapi/lib/model/email_notifications_response.dart differ 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 000000000..dcd1ec432 Binary files /dev/null and b/mobile/openapi/lib/model/email_notifications_update.dart differ diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index 673f5bfaf..4db710432 100644 Binary files a/mobile/openapi/lib/model/user_preferences_response_dto.dart and b/mobile/openapi/lib/model/user_preferences_response_dto.dart differ diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 887293931..21da7c7ba 100644 Binary files a/mobile/openapi/lib/model/user_preferences_update_dto.dart and b/mobile/openapi/lib/model/user_preferences_update_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5ffd86a07..eeef262ea 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 b533a8f3a..c835ff190 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 2dd9492d0..64120be22 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 26715e05e..b10945531 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 ddd8d61e3..8efc6a6c3 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 000000000..3005a08a8 --- /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 f88ee5887..95a792eb6 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}