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}