diff --git a/docs/docs/administration/email-notification.mdx b/docs/docs/administration/email-notification.mdx index 93b105105306..2f244f33521a 100644 --- a/docs/docs/administration/email-notification.mdx +++ b/docs/docs/administration/email-notification.mdx @@ -19,3 +19,9 @@ You can use [this guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server. Users can manage their email notification settings from their account settings page on the web. They can choose to turn email notifications on or off for the following events: + +## Notification templates + +You can override the default notification text with custom templates in HTML format. You can use tags to show dynamic tags in your templates. + + diff --git a/docs/docs/administration/img/user-notifications-templates.png b/docs/docs/administration/img/user-notifications-templates.png new file mode 100644 index 000000000000..150d39b7a6a2 Binary files /dev/null and b/docs/docs/administration/img/user-notifications-templates.png differ diff --git a/docs/docs/administration/system-settings.md b/docs/docs/administration/system-settings.md index 9f35ed1010e2..59012c99be12 100644 --- a/docs/docs/administration/system-settings.md +++ b/docs/docs/administration/system-settings.md @@ -157,6 +157,10 @@ Immich supports [Reverse Geocoding](/docs/features/reverse-geocoding) using data SMTP server setup, for user creation notifications, new albums, etc. More information can be found [here](/docs/administration/email-notification) +## Notification Templates + +Override the default notifications text with notification templates. More information can be found [here](/docs/administration/email-notification) + ## Server Settings ### External Domain diff --git a/i18n/en.json b/i18n/en.json index 907f5df182e4..9741c10b2970 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -252,6 +252,16 @@ "storage_template_user_label": "{label} is the user's Storage Label", "system_settings": "System Settings", "tag_cleanup_job": "Tag cleanup", + "template_email_preview": "Preview", + "template_email_settings": "Email Templates", + "template_email_settings_description": "Manage custom email notification templates", + "template_email_welcome": "Welcome email template", + "template_email_invite_album": "Invite Album Template", + "template_email_update_album": "Update Album Template", + "template_settings": "Notification Templates", + "template_settings_description": "Manage custom templates for notifications.", + "template_email_if_empty": "If the template is empty, the default email will be used.", + "template_email_available_tags": "You can use the following variables in your template: {tags}", "theme_custom_css_settings": "Custom CSS", "theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.", "theme_settings": "Theme Settings", @@ -1325,4 +1335,4 @@ "zoom_image": "Zoom Image", "timeline": "Timeline", "total": "Total" -} +} \ No newline at end of file diff --git a/i18n/nl.json b/i18n/nl.json index ade7a50925ed..3420c5d10585 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -247,6 +247,16 @@ "storage_template_user_label": "{label} is het opslaglabel van de gebruiker", "system_settings": "Systeeminstellingen", "tag_cleanup_job": "Tag opschoning", + "template_email_settings": "Email", + "template_email_settings_description": "Beheer aangepaste email melding sjablonen", + "template_email_preview": "Voorbeeld", + "template_email_welcome": "Welkom email sjabloon", + "template_email_invite_album": "Uitgenodigd in album sjabloon", + "template_email_update_album": "Update in album sjabloon", + "template_settings": "Melding sjablonen", + "template_settings_description": "Beheer aangepast sjablonen voor meldingen.", + "template_email_if_empty": "Wanneer het sjabloon leeg is, wordt de standaard mail gebruikt.", + "template_email_available_tags": "Je kan de volgende tags gebruiken in een template: {tags}", "theme_custom_css_settings": "Aangepaste CSS", "theme_custom_css_settings_description": "Met Cascading Style Sheets kan het ontwerp van Immich worden aangepast.", "theme_settings": "Thema instellingen", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 77809359023b..b97ff5411cbb 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -144,6 +144,7 @@ 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} | +*NotificationsApi* | [**getNotificationTemplate**](doc//NotificationsApi.md#getnotificationtemplate) | **POST** /notifications/templates/{name} | *NotificationsApi* | [**sendTestEmail**](doc//NotificationsApi.md#sendtestemail) | **POST** /notifications/test-email | *OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback | *OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link | @@ -436,7 +437,9 @@ Class | Method | HTTP request | Description - [SystemConfigSmtpDto](doc//SystemConfigSmtpDto.md) - [SystemConfigSmtpTransportDto](doc//SystemConfigSmtpTransportDto.md) - [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md) + - [SystemConfigTemplateEmailsDto](doc//SystemConfigTemplateEmailsDto.md) - [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md) + - [SystemConfigTemplatesDto](doc//SystemConfigTemplatesDto.md) - [SystemConfigThemeDto](doc//SystemConfigThemeDto.md) - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md) - [SystemConfigUserDto](doc//SystemConfigUserDto.md) @@ -448,6 +451,8 @@ Class | Method | HTTP request | Description - [TagUpsertDto](doc//TagUpsertDto.md) - [TagsResponse](doc//TagsResponse.md) - [TagsUpdate](doc//TagsUpdate.md) + - [TemplateDto](doc//TemplateDto.md) + - [TemplateResponseDto](doc//TemplateResponseDto.md) - [TestEmailResponseDto](doc//TestEmailResponseDto.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketSize](doc//TimeBucketSize.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e1c343ad50d1..73eb02d89ed7 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -250,7 +250,9 @@ part 'model/system_config_server_dto.dart'; part 'model/system_config_smtp_dto.dart'; part 'model/system_config_smtp_transport_dto.dart'; part 'model/system_config_storage_template_dto.dart'; +part 'model/system_config_template_emails_dto.dart'; part 'model/system_config_template_storage_option_dto.dart'; +part 'model/system_config_templates_dto.dart'; part 'model/system_config_theme_dto.dart'; part 'model/system_config_trash_dto.dart'; part 'model/system_config_user_dto.dart'; @@ -262,6 +264,8 @@ part 'model/tag_update_dto.dart'; part 'model/tag_upsert_dto.dart'; part 'model/tags_response.dart'; part 'model/tags_update.dart'; +part 'model/template_dto.dart'; +part 'model/template_response_dto.dart'; part 'model/test_email_response_dto.dart'; part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_size.dart'; diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart index 0681d582479a..323fbcc3d6bc 100644 --- a/mobile/openapi/lib/api/notifications_api.dart +++ b/mobile/openapi/lib/api/notifications_api.dart @@ -16,6 +16,58 @@ class NotificationsApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /notifications/templates/{name}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] name (required): + /// + /// * [TemplateDto] templateDto (required): + Future getNotificationTemplateWithHttpInfo(String name, TemplateDto templateDto,) async { + // ignore: prefer_const_declarations + final path = r'/notifications/templates/{name}' + .replaceAll('{name}', name); + + // ignore: prefer_final_locals + Object? postBody = templateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] name (required): + /// + /// * [TemplateDto] templateDto (required): + Future getNotificationTemplate(String name, TemplateDto templateDto,) async { + final response = await getNotificationTemplateWithHttpInfo(name, templateDto,); + 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), 'TemplateResponseDto',) as TemplateResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /notifications/test-email' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index b71e6f45f71c..a6f8d551da81 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -554,8 +554,12 @@ class ApiClient { return SystemConfigSmtpTransportDto.fromJson(value); case 'SystemConfigStorageTemplateDto': return SystemConfigStorageTemplateDto.fromJson(value); + case 'SystemConfigTemplateEmailsDto': + return SystemConfigTemplateEmailsDto.fromJson(value); case 'SystemConfigTemplateStorageOptionDto': return SystemConfigTemplateStorageOptionDto.fromJson(value); + case 'SystemConfigTemplatesDto': + return SystemConfigTemplatesDto.fromJson(value); case 'SystemConfigThemeDto': return SystemConfigThemeDto.fromJson(value); case 'SystemConfigTrashDto': @@ -578,6 +582,10 @@ class ApiClient { return TagsResponse.fromJson(value); case 'TagsUpdate': return TagsUpdate.fromJson(value); + case 'TemplateDto': + return TemplateDto.fromJson(value); + case 'TemplateResponseDto': + return TemplateResponseDto.fromJson(value); case 'TestEmailResponseDto': return TestEmailResponseDto.fromJson(value); case 'TimeBucketResponseDto': diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index 421595390660..59d5f09fc946 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -29,6 +29,7 @@ class SystemConfigDto { required this.reverseGeocoding, required this.server, required this.storageTemplate, + required this.templates, required this.theme, required this.trash, required this.user, @@ -66,6 +67,8 @@ class SystemConfigDto { SystemConfigStorageTemplateDto storageTemplate; + SystemConfigTemplatesDto templates; + SystemConfigThemeDto theme; SystemConfigTrashDto trash; @@ -90,6 +93,7 @@ class SystemConfigDto { other.reverseGeocoding == reverseGeocoding && other.server == server && other.storageTemplate == storageTemplate && + other.templates == templates && other.theme == theme && other.trash == trash && other.user == user; @@ -113,12 +117,13 @@ class SystemConfigDto { (reverseGeocoding.hashCode) + (server.hashCode) + (storageTemplate.hashCode) + + (templates.hashCode) + (theme.hashCode) + (trash.hashCode) + (user.hashCode); @override - String toString() => 'SystemConfigDto[backup=$backup, ffmpeg=$ffmpeg, image=$image, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, metadata=$metadata, newVersionCheck=$newVersionCheck, notifications=$notifications, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, trash=$trash, user=$user]'; + String toString() => 'SystemConfigDto[backup=$backup, ffmpeg=$ffmpeg, image=$image, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, metadata=$metadata, newVersionCheck=$newVersionCheck, notifications=$notifications, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, templates=$templates, theme=$theme, trash=$trash, user=$user]'; Map toJson() { final json = {}; @@ -138,6 +143,7 @@ class SystemConfigDto { json[r'reverseGeocoding'] = this.reverseGeocoding; json[r'server'] = this.server; json[r'storageTemplate'] = this.storageTemplate; + json[r'templates'] = this.templates; json[r'theme'] = this.theme; json[r'trash'] = this.trash; json[r'user'] = this.user; @@ -169,6 +175,7 @@ class SystemConfigDto { reverseGeocoding: SystemConfigReverseGeocodingDto.fromJson(json[r'reverseGeocoding'])!, server: SystemConfigServerDto.fromJson(json[r'server'])!, storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!, + templates: SystemConfigTemplatesDto.fromJson(json[r'templates'])!, theme: SystemConfigThemeDto.fromJson(json[r'theme'])!, trash: SystemConfigTrashDto.fromJson(json[r'trash'])!, user: SystemConfigUserDto.fromJson(json[r'user'])!, @@ -235,6 +242,7 @@ class SystemConfigDto { 'reverseGeocoding', 'server', 'storageTemplate', + 'templates', 'theme', 'trash', 'user', diff --git a/mobile/openapi/lib/model/system_config_template_emails_dto.dart b/mobile/openapi/lib/model/system_config_template_emails_dto.dart new file mode 100644 index 000000000000..9db85509f58e --- /dev/null +++ b/mobile/openapi/lib/model/system_config_template_emails_dto.dart @@ -0,0 +1,115 @@ +// +// 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 SystemConfigTemplateEmailsDto { + /// Returns a new [SystemConfigTemplateEmailsDto] instance. + SystemConfigTemplateEmailsDto({ + required this.albumInviteTemplate, + required this.albumUpdateTemplate, + required this.welcomeTemplate, + }); + + String albumInviteTemplate; + + String albumUpdateTemplate; + + String welcomeTemplate; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigTemplateEmailsDto && + other.albumInviteTemplate == albumInviteTemplate && + other.albumUpdateTemplate == albumUpdateTemplate && + other.welcomeTemplate == welcomeTemplate; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albumInviteTemplate.hashCode) + + (albumUpdateTemplate.hashCode) + + (welcomeTemplate.hashCode); + + @override + String toString() => 'SystemConfigTemplateEmailsDto[albumInviteTemplate=$albumInviteTemplate, albumUpdateTemplate=$albumUpdateTemplate, welcomeTemplate=$welcomeTemplate]'; + + Map toJson() { + final json = {}; + json[r'albumInviteTemplate'] = this.albumInviteTemplate; + json[r'albumUpdateTemplate'] = this.albumUpdateTemplate; + json[r'welcomeTemplate'] = this.welcomeTemplate; + return json; + } + + /// Returns a new [SystemConfigTemplateEmailsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigTemplateEmailsDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigTemplateEmailsDto"); + if (value is Map) { + final json = value.cast(); + + return SystemConfigTemplateEmailsDto( + albumInviteTemplate: mapValueOfType(json, r'albumInviteTemplate')!, + albumUpdateTemplate: mapValueOfType(json, r'albumUpdateTemplate')!, + welcomeTemplate: mapValueOfType(json, r'welcomeTemplate')!, + ); + } + 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 = SystemConfigTemplateEmailsDto.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 = SystemConfigTemplateEmailsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigTemplateEmailsDto-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] = SystemConfigTemplateEmailsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albumInviteTemplate', + 'albumUpdateTemplate', + 'welcomeTemplate', + }; +} + diff --git a/mobile/openapi/lib/model/system_config_templates_dto.dart b/mobile/openapi/lib/model/system_config_templates_dto.dart new file mode 100644 index 000000000000..a5e883497881 --- /dev/null +++ b/mobile/openapi/lib/model/system_config_templates_dto.dart @@ -0,0 +1,99 @@ +// +// 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 SystemConfigTemplatesDto { + /// Returns a new [SystemConfigTemplatesDto] instance. + SystemConfigTemplatesDto({ + required this.email, + }); + + SystemConfigTemplateEmailsDto email; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigTemplatesDto && + other.email == email; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (email.hashCode); + + @override + String toString() => 'SystemConfigTemplatesDto[email=$email]'; + + Map toJson() { + final json = {}; + json[r'email'] = this.email; + return json; + } + + /// Returns a new [SystemConfigTemplatesDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigTemplatesDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigTemplatesDto"); + if (value is Map) { + final json = value.cast(); + + return SystemConfigTemplatesDto( + email: SystemConfigTemplateEmailsDto.fromJson(json[r'email'])!, + ); + } + 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 = SystemConfigTemplatesDto.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 = SystemConfigTemplatesDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigTemplatesDto-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] = SystemConfigTemplatesDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'email', + }; +} + diff --git a/mobile/openapi/lib/model/template_dto.dart b/mobile/openapi/lib/model/template_dto.dart new file mode 100644 index 000000000000..f818e0508aca --- /dev/null +++ b/mobile/openapi/lib/model/template_dto.dart @@ -0,0 +1,99 @@ +// +// 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 TemplateDto { + /// Returns a new [TemplateDto] instance. + TemplateDto({ + required this.template, + }); + + String template; + + @override + bool operator ==(Object other) => identical(this, other) || other is TemplateDto && + other.template == template; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (template.hashCode); + + @override + String toString() => 'TemplateDto[template=$template]'; + + Map toJson() { + final json = {}; + json[r'template'] = this.template; + return json; + } + + /// Returns a new [TemplateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TemplateDto? fromJson(dynamic value) { + upgradeDto(value, "TemplateDto"); + if (value is Map) { + final json = value.cast(); + + return TemplateDto( + template: mapValueOfType(json, r'template')!, + ); + } + 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 = TemplateDto.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 = TemplateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TemplateDto-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] = TemplateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'template', + }; +} + diff --git a/mobile/openapi/lib/model/template_response_dto.dart b/mobile/openapi/lib/model/template_response_dto.dart new file mode 100644 index 000000000000..3c3224a54beb --- /dev/null +++ b/mobile/openapi/lib/model/template_response_dto.dart @@ -0,0 +1,107 @@ +// +// 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 TemplateResponseDto { + /// Returns a new [TemplateResponseDto] instance. + TemplateResponseDto({ + required this.html, + required this.name, + }); + + String html; + + String name; + + @override + bool operator ==(Object other) => identical(this, other) || other is TemplateResponseDto && + other.html == html && + other.name == name; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (html.hashCode) + + (name.hashCode); + + @override + String toString() => 'TemplateResponseDto[html=$html, name=$name]'; + + Map toJson() { + final json = {}; + json[r'html'] = this.html; + json[r'name'] = this.name; + return json; + } + + /// Returns a new [TemplateResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TemplateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TemplateResponseDto"); + if (value is Map) { + final json = value.cast(); + + return TemplateResponseDto( + html: mapValueOfType(json, r'html')!, + name: mapValueOfType(json, r'name')!, + ); + } + 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 = TemplateResponseDto.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 = TemplateResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TemplateResponseDto-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] = TemplateResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'html', + 'name', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bc32a32e04f0..43985cae8141 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3430,6 +3430,57 @@ ] } }, + "/notifications/templates/{name}": { + "post": { + "operationId": "getNotificationTemplate", + "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" + ] + } + }, "/notifications/test-email": { "post": { "operationId": "sendTestEmail", @@ -11538,6 +11589,9 @@ "storageTemplate": { "$ref": "#/components/schemas/SystemConfigStorageTemplateDto" }, + "templates": { + "$ref": "#/components/schemas/SystemConfigTemplatesDto" + }, "theme": { "$ref": "#/components/schemas/SystemConfigThemeDto" }, @@ -11565,6 +11619,7 @@ "reverseGeocoding", "server", "storageTemplate", + "templates", "theme", "trash", "user" @@ -12111,6 +12166,25 @@ ], "type": "object" }, + "SystemConfigTemplateEmailsDto": { + "properties": { + "albumInviteTemplate": { + "type": "string" + }, + "albumUpdateTemplate": { + "type": "string" + }, + "welcomeTemplate": { + "type": "string" + } + }, + "required": [ + "albumInviteTemplate", + "albumUpdateTemplate", + "welcomeTemplate" + ], + "type": "object" + }, "SystemConfigTemplateStorageOptionDto": { "properties": { "dayOptions": { @@ -12174,6 +12248,17 @@ ], "type": "object" }, + "SystemConfigTemplatesDto": { + "properties": { + "email": { + "$ref": "#/components/schemas/SystemConfigTemplateEmailsDto" + } + }, + "required": [ + "email" + ], + "type": "object" + }, "SystemConfigThemeDto": { "properties": { "customCss": { @@ -12352,6 +12437,32 @@ }, "type": "object" }, + "TemplateDto": { + "properties": { + "template": { + "type": "string" + } + }, + "required": [ + "template" + ], + "type": "object" + }, + "TemplateResponseDto": { + "properties": { + "html": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "html", + "name" + ], + "type": "object" + }, "TestEmailResponseDto": { "properties": { "messageId": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d786139ab51f..20d0c5715fa9 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -634,6 +634,13 @@ export type MemoryUpdateDto = { memoryAt?: string; seenAt?: string; }; +export type TemplateDto = { + template: string; +}; +export type TemplateResponseDto = { + html: string; + name: string; +}; export type SystemConfigSmtpTransportDto = { host: string; ignoreCert: boolean; @@ -1232,6 +1239,14 @@ export type SystemConfigStorageTemplateDto = { hashVerificationEnabled: boolean; template: string; }; +export type SystemConfigTemplateEmailsDto = { + albumInviteTemplate: string; + albumUpdateTemplate: string; + welcomeTemplate: string; +}; +export type SystemConfigTemplatesDto = { + email: SystemConfigTemplateEmailsDto; +}; export type SystemConfigThemeDto = { customCss: string; }; @@ -1259,6 +1274,7 @@ export type SystemConfigDto = { reverseGeocoding: SystemConfigReverseGeocodingDto; server: SystemConfigServerDto; storageTemplate: SystemConfigStorageTemplateDto; + templates: SystemConfigTemplatesDto; theme: SystemConfigThemeDto; trash: SystemConfigTrashDto; user: SystemConfigUserDto; @@ -2227,6 +2243,19 @@ export function addMemoryAssets({ id, bulkIdsDto }: { body: bulkIdsDto }))); } +export function getNotificationTemplate({ name, templateDto }: { + name: string; + templateDto: TemplateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TemplateResponseDto; + }>(`/notifications/templates/${encodeURIComponent(name)}`, oazapfts.json({ + ...opts, + method: "POST", + body: templateDto + }))); +} export function sendTestEmail({ systemConfigSmtpDto }: { systemConfigSmtpDto: SystemConfigSmtpDto; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/config.ts b/server/src/config.ts index dd850e063f0d..26589742003e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -146,6 +146,13 @@ export interface SystemConfig { }; }; }; + templates: { + email: { + welcomeTemplate: string; + albumInviteTemplate: string; + albumUpdateTemplate: string; + }; + }; server: { externalDomain: string; loginPageMessage: string; @@ -313,6 +320,13 @@ export const defaults = Object.freeze({ }, }, }, + templates: { + email: { + welcomeTemplate: '', + albumInviteTemplate: '', + albumUpdateTemplate: '', + }, + }, user: { deleteDelay: 7, }, diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts index 3dd72dd73a91..27034fd63a87 100644 --- a/server/src/controllers/notification.controller.ts +++ b/server/src/controllers/notification.controller.ts @@ -1,8 +1,9 @@ -import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { TestEmailResponseDto } from 'src/dtos/notification.dto'; +import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; +import { EmailTemplate } from 'src/interfaces/notification.interface'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { NotificationService } from 'src/services/notification.service'; @@ -17,4 +18,15 @@ export class NotificationController { sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise { return this.service.sendTestEmail(auth.user.id, dto); } + + @Post('templates/:name') + @HttpCode(HttpStatus.OK) + @Authenticated({ admin: true }) + getNotificationTemplate( + @Auth() auth: AuthDto, + @Param('name') name: EmailTemplate, + @Body() dto: TemplateDto, + ): Promise { + return this.service.getTemplate(name, dto.template); + } } diff --git a/server/src/dtos/notification.dto.ts b/server/src/dtos/notification.dto.ts index 34b392358083..c1a09c801c89 100644 --- a/server/src/dtos/notification.dto.ts +++ b/server/src/dtos/notification.dto.ts @@ -1,3 +1,13 @@ +import { IsString } from 'class-validator'; + export class TestEmailResponseDto { messageId!: string; } +export class TemplateResponseDto { + name!: string; + html!: string; +} +export class TemplateDto { + @IsString() + template!: string; +} diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 894f4c7948ca..350918254542 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -465,6 +465,24 @@ class SystemConfigNotificationsDto { smtp!: SystemConfigSmtpDto; } +class SystemConfigTemplateEmailsDto { + @IsString() + albumInviteTemplate!: string; + + @IsString() + welcomeTemplate!: string; + + @IsString() + albumUpdateTemplate!: string; +} + +class SystemConfigTemplatesDto { + @Type(() => SystemConfigTemplateEmailsDto) + @ValidateNested() + @IsObject() + email!: SystemConfigTemplateEmailsDto; +} + class SystemConfigStorageTemplateDto { @ValidateBoolean() enabled!: boolean; @@ -636,6 +654,11 @@ export class SystemConfigDto implements SystemConfig { @IsObject() notifications!: SystemConfigNotificationsDto; + @Type(() => SystemConfigTemplatesDto) + @ValidateNested() + @IsObject() + templates!: SystemConfigTemplatesDto; + @Type(() => SystemConfigServerDto) @ValidateNested() @IsObject() diff --git a/server/src/emails/album-invite.email.tsx b/server/src/emails/album-invite.email.tsx index 232ef5290d6d..0b3819b332b5 100644 --- a/server/src/emails/album-invite.email.tsx +++ b/server/src/emails/album-invite.email.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { ImmichButton } from 'src/emails/components/button.component'; import ImmichLayout from 'src/emails/components/immich.layout'; import { AlbumInviteEmailProps } from 'src/interfaces/notification.interface'; +import { replaceTemplateTags } from 'src/utils/replace-template-tags'; export const AlbumInviteEmail = ({ baseUrl, @@ -11,39 +12,64 @@ export const AlbumInviteEmail = ({ senderName, albumId, cid, -}: AlbumInviteEmailProps) => ( - - - Hey {recipientName}! - + customTemplate, +}: AlbumInviteEmailProps) => { + const variables = { + albumName, + recipientName, + senderName, + albumId, + baseUrl, + }; - - {senderName} has added you to the album {albumName}. - + const emailContent = customTemplate ? ( + replaceTemplateTags(customTemplate, variables) + ) : ( + <> + + Hey {recipientName}! + - {cid && ( -
- + + {senderName} has added you to the album {albumName}. + + + ); + + return ( + + {customTemplate && ( + +
+
+ )} + + {!customTemplate && emailContent} + + {cid && ( +
+ +
+ )} + +
+ View Album
- )} -
- View Album -
- - - If you cannot click the button use the link below to view the album. -
- {`${baseUrl}/albums/${albumId}`} -
-
-); + + If you cannot click the button use the link below to view the album. +
+ {`${baseUrl}/albums/${albumId}`} +
+ + ); +}; AlbumInviteEmail.PreviewProps = { baseUrl: 'https://demo.immich.app', diff --git a/server/src/emails/album-update.email.tsx b/server/src/emails/album-update.email.tsx index 0fb5ad931c9f..9dcd858e93e0 100644 --- a/server/src/emails/album-update.email.tsx +++ b/server/src/emails/album-update.email.tsx @@ -3,47 +3,80 @@ import * as React from 'react'; import { ImmichButton } from 'src/emails/components/button.component'; import ImmichLayout from 'src/emails/components/immich.layout'; import { AlbumUpdateEmailProps } from 'src/interfaces/notification.interface'; +import { replaceTemplateTags } from 'src/utils/replace-template-tags'; -export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, cid }: AlbumUpdateEmailProps) => ( - - - Hey {recipientName}! - +export const AlbumUpdateEmail = ({ + baseUrl, + albumName, + recipientName, + albumId, + cid, + customTemplate, +}: AlbumUpdateEmailProps) => { + const usableTemplateVariables = { + albumName, + recipientName, + albumId, + baseUrl, + }; - - New media has been added to {albumName}, -
check it out! -
+ const emailContent = customTemplate ? ( + replaceTemplateTags(customTemplate, usableTemplateVariables) + ) : ( + <> + + Hey {recipientName}! + - {cid && ( -
- + + New media has been added to {albumName}, +
check it out! +
+ + ); + + return ( + + {customTemplate && ( + +
+
+ )} + + {!customTemplate && emailContent} + + {cid && ( +
+ +
+ )} + +
+ View Album
- )} -
- View Album -
- - - If you cannot click the button use the link below to view the album. -
- {`${baseUrl}/albums/${albumId}`} -
-
-); + + If you cannot click the button use the link below to view the album. +
+ {`${baseUrl}/albums/${albumId}`} +
+ + ); +}; AlbumUpdateEmail.PreviewProps = { baseUrl: 'https://demo.immich.app', albumName: 'Trip to Europe', albumId: 'b63f6dae-e1c9-401b-9a85-9dbbf5612539', recipientName: 'Alan Turing', + cid: '', + customTemplate: '', } as AlbumUpdateEmailProps; export default AlbumUpdateEmail; diff --git a/server/src/emails/welcome.email.tsx b/server/src/emails/welcome.email.tsx index e031ac6b9713..ced0b7769883 100644 --- a/server/src/emails/welcome.email.tsx +++ b/server/src/emails/welcome.email.tsx @@ -3,36 +3,62 @@ import * as React from 'react'; import { ImmichButton } from 'src/emails/components/button.component'; import ImmichLayout from 'src/emails/components/immich.layout'; import { WelcomeEmailProps } from 'src/interfaces/notification.interface'; +import { replaceTemplateTags } from 'src/utils/replace-template-tags'; -export const WelcomeEmail = ({ baseUrl, displayName, username, password }: WelcomeEmailProps) => ( - - - Hey {displayName}! - +export const WelcomeEmail = ({ baseUrl, displayName, username, password, customTemplate }: WelcomeEmailProps) => { + const usableTemplateVariables = { + displayName, + username, + password, + baseUrl, + }; - A new account has been created for you. + const emailContent = customTemplate ? ( + replaceTemplateTags(customTemplate, usableTemplateVariables) + ) : ( + <> + + Hey {displayName}! + - - Username: {username} - {password && ( - <> -
- Password: {password} - + A new account has been created for you. + + + Username: {username} + {password && ( + <> +
+ Password: {password} + + )} +
+ + ); + + return ( + + {customTemplate && ( + +
+
)} -
-
- Login -
+ {!customTemplate && emailContent} - - If you cannot click the button use the link below to proceed with first login. -
- {baseUrl} -
-
-); +
+ Login +
+ + + If you cannot click the button use the link below to proceed with first login. +
+ {baseUrl} +
+ + ); +}; WelcomeEmail.PreviewProps = { baseUrl: 'https://demo.immich.app/auth/login', diff --git a/server/src/interfaces/notification.interface.ts b/server/src/interfaces/notification.interface.ts index ec0ecc534b6b..b20b3c50aee8 100644 --- a/server/src/interfaces/notification.interface.ts +++ b/server/src/interfaces/notification.interface.ts @@ -39,6 +39,7 @@ export enum EmailTemplate { interface BaseEmailProps { baseUrl: string; + customTemplate?: string; } export interface TestEmailProps extends BaseEmailProps { @@ -70,18 +71,22 @@ export type EmailRenderRequest = | { template: EmailTemplate.TEST_EMAIL; data: TestEmailProps; + customTemplate: string; } | { template: EmailTemplate.WELCOME; data: WelcomeEmailProps; + customTemplate: string; } | { template: EmailTemplate.ALBUM_INVITE; data: AlbumInviteEmailProps; + customTemplate: string; } | { template: EmailTemplate.ALBUM_UPDATE; data: AlbumUpdateEmailProps; + customTemplate: string; }; export type SendEmailResponse = { diff --git a/server/src/repositories/notification.repository.spec.ts b/server/src/repositories/notification.repository.spec.ts index 983be21d2b90..368ba3f0ce3f 100644 --- a/server/src/repositories/notification.repository.spec.ts +++ b/server/src/repositories/notification.repository.spec.ts @@ -21,6 +21,7 @@ describe(NotificationRepository.name, () => { const request: EmailRenderRequest = { template: EmailTemplate.TEST_EMAIL, data: { displayName: 'Alen Turing', baseUrl: 'http://localhost' }, + customTemplate: '', }; const result = await sut.renderEmail(request); @@ -33,6 +34,7 @@ describe(NotificationRepository.name, () => { const request: EmailRenderRequest = { template: EmailTemplate.WELCOME, data: { displayName: 'Alen Turing', username: 'turing', baseUrl: 'http://localhost' }, + customTemplate: '', }; const result = await sut.renderEmail(request); @@ -51,6 +53,7 @@ describe(NotificationRepository.name, () => { recipientName: 'Jane', baseUrl: 'http://localhost', }, + customTemplate: '', }; const result = await sut.renderEmail(request); @@ -63,6 +66,7 @@ describe(NotificationRepository.name, () => { const request: EmailRenderRequest = { template: EmailTemplate.ALBUM_UPDATE, data: { albumName: 'Holiday', albumId: '123', recipientName: 'Jane', baseUrl: 'http://localhost' }, + customTemplate: '', }; const result = await sut.renderEmail(request); diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts index 293a80576fa7..b2444301e533 100644 --- a/server/src/repositories/notification.repository.ts +++ b/server/src/repositories/notification.repository.ts @@ -55,22 +55,22 @@ export class NotificationRepository implements INotificationRepository { } } - private render({ template, data }: EmailRenderRequest): React.FunctionComponentElement { + private render({ template, data, customTemplate }: EmailRenderRequest): React.FunctionComponentElement { switch (template) { case EmailTemplate.TEST_EMAIL: { - return React.createElement(TestEmail, data); + return React.createElement(TestEmail, { ...data, customTemplate }); } case EmailTemplate.WELCOME: { - return React.createElement(WelcomeEmail, data); + return React.createElement(WelcomeEmail, { ...data, customTemplate }); } case EmailTemplate.ALBUM_INVITE: { - return React.createElement(AlbumInviteEmail, data); + return React.createElement(AlbumInviteEmail, { ...data, customTemplate }); } case EmailTemplate.ALBUM_UPDATE: { - return React.createElement(AlbumUpdateEmail, data); + return React.createElement(AlbumUpdateEmail, { ...data, customTemplate }); } } } diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index e7c020196303..37b265c6ae74 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -140,7 +140,7 @@ export class NotificationService extends BaseService { setTimeout(() => this.eventRepository.clientSend('on_session_delete', sessionId, sessionId), 500); } - async sendTestEmail(id: string, dto: SystemConfigSmtpDto) { + 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'); @@ -160,8 +160,8 @@ export class NotificationService extends BaseService { baseUrl: getExternalDomain(server, port), displayName: user.name, }, + customTemplate: tempTemplate!, }); - const { messageId } = await this.notificationRepository.sendEmail({ to: user.email, subject: 'Test email from Immich', @@ -175,6 +175,69 @@ export class NotificationService extends BaseService { return { messageId }; } + async getTemplate(name: EmailTemplate, customTemplate: string) { + const { server, templates } = await this.getConfig({ withCache: false }); + const { port } = this.configRepository.getEnv(); + + let templateResponse = ''; + + switch (name) { + case EmailTemplate.WELCOME: { + const { html: _welcomeHtml } = await this.notificationRepository.renderEmail({ + template: EmailTemplate.WELCOME, + data: { + baseUrl: getExternalDomain(server, port), + 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.notificationRepository.renderEmail({ + template: EmailTemplate.ALBUM_UPDATE, + data: { + baseUrl: getExternalDomain(server, port), + 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.notificationRepository.renderEmail({ + template: EmailTemplate.ALBUM_INVITE, + data: { + baseUrl: getExternalDomain(server, port), + 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 }; + } + @OnJob({ name: JobName.NOTIFY_SIGNUP, queue: QueueName.NOTIFICATION }) async handleUserSignup({ id, tempPassword }: JobOf) { const user = await this.userRepository.get(id, { withDeleted: false }); @@ -182,7 +245,7 @@ export class NotificationService extends BaseService { return JobStatus.SKIPPED; } - const { server } = await this.getConfig({ withCache: true }); + const { server, templates } = await this.getConfig({ withCache: true }); const { port } = this.configRepository.getEnv(); const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.WELCOME, @@ -192,6 +255,7 @@ export class NotificationService extends BaseService { username: user.email, password: tempPassword, }, + customTemplate: templates.email.welcomeTemplate, }); await this.jobRepository.queue({ @@ -227,7 +291,7 @@ export class NotificationService extends BaseService { const attachment = await this.getAlbumThumbnailAttachment(album); - const { server } = await this.getConfig({ withCache: false }); + const { server, templates } = await this.getConfig({ withCache: false }); const { port } = this.configRepository.getEnv(); const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.ALBUM_INVITE, @@ -239,6 +303,7 @@ export class NotificationService extends BaseService { recipientName: recipient.name, cid: attachment ? attachment.cid : undefined, }, + customTemplate: templates.email.albumInviteTemplate, }); await this.jobRepository.queue({ @@ -273,7 +338,7 @@ export class NotificationService extends BaseService { ); const attachment = await this.getAlbumThumbnailAttachment(album); - const { server } = await this.getConfig({ withCache: false }); + const { server, templates } = await this.getConfig({ withCache: false }); const { port } = this.configRepository.getEnv(); for (const recipient of recipients) { @@ -297,6 +362,7 @@ export class NotificationService extends BaseService { recipientName: recipient.name, cid: attachment ? attachment.cid : undefined, }, + customTemplate: templates.email.albumUpdateTemplate, }); await this.jobRepository.queue({ diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 2550c15de25c..2a20f329330a 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -190,6 +190,13 @@ const updatedConfig = Object.freeze({ }, }, }, + templates: { + email: { + albumInviteTemplate: '', + welcomeTemplate: '', + albumUpdateTemplate: '', + }, + }, }); describe(SystemConfigService.name, () => { diff --git a/server/src/utils/replace-template-tags.ts b/server/src/utils/replace-template-tags.ts new file mode 100644 index 000000000000..70333d7dfff5 --- /dev/null +++ b/server/src/utils/replace-template-tags.ts @@ -0,0 +1,5 @@ +export const replaceTemplateTags = (template: string, variables: Record) => { + return template.replaceAll(/{(.*?)}/g, (_, key) => { + return variables[key] || `{${key}}`; + }); +}; diff --git a/web/package-lock.json b/web/package-lock.json index f06484fe8f75..15edeb0c289c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -23,7 +23,7 @@ "justified-layout": "^4.1.0", "lodash-es": "^4.17.21", "luxon": "^3.4.4", - "socket.io-client": "^4.7.5", + "socket.io-client": "~4.7.5", "svelte-gestures": "^5.0.4", "svelte-i18n": "^4.0.1", "svelte-local-storage-store": "^0.6.4", diff --git a/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte b/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte index 28187978f958..30a9fbad5c62 100644 --- a/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte +++ b/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte @@ -17,6 +17,7 @@ import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { handleError } from '$lib/utils/handle-error'; import { SettingInputFieldType } from '$lib/constants'; + import TemplateSettings from '$lib/components/admin-page/settings/template-settings/template-settings.svelte'; interface Props { savedConfig: SystemConfigDto; @@ -162,13 +163,14 @@ - - onReset({ ...options, configKeys: ['notifications'] })} - onSave={() => onSave({ notifications: config.notifications })} - showResetToDefault={!isEqual(savedConfig, defaultConfig)} - {disabled} - /> + + + onReset({ ...options, configKeys: ['notifications', 'templates'] })} + onSave={() => onSave({ notifications: config.notifications, templates: config.templates })} + showResetToDefault={!isEqual(savedConfig, defaultConfig)} + {disabled} + /> diff --git a/web/src/lib/components/admin-page/settings/template-settings/template-settings.svelte b/web/src/lib/components/admin-page/settings/template-settings/template-settings.svelte new file mode 100644 index 000000000000..c27df817c291 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/template-settings/template-settings.svelte @@ -0,0 +1,131 @@ + + +
+
+
+
+ +
+

+ + {$t('admin.template_email_if_empty')} + +

+
+ {#if loadingPreview} + + {/if} + + {#each templateConfigs as { label, templateKey, descriptionTags, templateName }} + +
+ +
+ {/each} +
+
+
+ + {#if htmlPreview} + +
+ +
+
+ {/if} +
+
+