1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-03-01 15:11:21 +01:00

feat: Notification Email Templates (#13940)

This commit is contained in:
Tim Van Onckelen 2024-12-04 21:26:02 +01:00 committed by GitHub
parent 4bf1b84cc2
commit 292182fa7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1136 additions and 105 deletions

View file

@ -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:
<img src={require('./img/user-notifications-settings.png').default} width="80%" title="User notification settings" />
## 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.
<img src={require('./img/user-notifications-templates.png').default} width="80%" title="User notification templates" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

View file

@ -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

View file

@ -252,6 +252,16 @@
"storage_template_user_label": "<code>{label}</code> 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",

View file

@ -247,6 +247,16 @@
"storage_template_user_label": "<code>{label}</code> 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",

View file

@ -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)

View file

@ -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';

View file

@ -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<Response> 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 = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] name (required):
///
/// * [TemplateDto] templateDto (required):
Future<TemplateResponseDto?> 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:
///

View file

@ -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':

View file

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -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',

View file

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return SystemConfigTemplateEmailsDto(
albumInviteTemplate: mapValueOfType<String>(json, r'albumInviteTemplate')!,
albumUpdateTemplate: mapValueOfType<String>(json, r'albumUpdateTemplate')!,
welcomeTemplate: mapValueOfType<String>(json, r'welcomeTemplate')!,
);
}
return null;
}
static List<SystemConfigTemplateEmailsDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SystemConfigTemplateEmailsDto>[];
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<String, SystemConfigTemplateEmailsDto> mapFromJson(dynamic json) {
final map = <String, SystemConfigTemplateEmailsDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<SystemConfigTemplateEmailsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SystemConfigTemplateEmailsDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'albumInviteTemplate',
'albumUpdateTemplate',
'welcomeTemplate',
};
}

View file

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return SystemConfigTemplatesDto(
email: SystemConfigTemplateEmailsDto.fromJson(json[r'email'])!,
);
}
return null;
}
static List<SystemConfigTemplatesDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SystemConfigTemplatesDto>[];
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<String, SystemConfigTemplatesDto> mapFromJson(dynamic json) {
final map = <String, SystemConfigTemplatesDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<SystemConfigTemplatesDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SystemConfigTemplatesDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'email',
};
}

View file

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return TemplateDto(
template: mapValueOfType<String>(json, r'template')!,
);
}
return null;
}
static List<TemplateDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <TemplateDto>[];
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<String, TemplateDto> mapFromJson(dynamic json) {
final map = <String, TemplateDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<TemplateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<TemplateDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'template',
};
}

View file

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return TemplateResponseDto(
html: mapValueOfType<String>(json, r'html')!,
name: mapValueOfType<String>(json, r'name')!,
);
}
return null;
}
static List<TemplateResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <TemplateResponseDto>[];
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<String, TemplateResponseDto> mapFromJson(dynamic json) {
final map = <String, TemplateResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<TemplateResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<TemplateResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'html',
'name',
};
}

View file

@ -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": {

View file

@ -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) {

View file

@ -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<SystemConfig>({
},
},
},
templates: {
email: {
welcomeTemplate: '',
albumInviteTemplate: '',
albumUpdateTemplate: '',
},
},
user: {
deleteDelay: 7,
},

View file

@ -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<TestEmailResponseDto> {
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<TemplateResponseDto> {
return this.service.getTemplate(name, dto.template);
}
}

View file

@ -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;
}

View file

@ -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()

View file

@ -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,8 +12,20 @@ export const AlbumInviteEmail = ({
senderName,
albumId,
cid,
}: AlbumInviteEmailProps) => (
<ImmichLayout preview="You have been added to a shared album.">
customTemplate,
}: AlbumInviteEmailProps) => {
const variables = {
albumName,
recipientName,
senderName,
albumId,
baseUrl,
};
const emailContent = customTemplate ? (
replaceTemplateTags(customTemplate, variables)
) : (
<>
<Text className="m-0">
Hey <strong>{recipientName}</strong>!
</Text>
@ -20,6 +33,18 @@ export const AlbumInviteEmail = ({
<Text>
{senderName} has added you to the album <strong>{albumName}</strong>.
</Text>
</>
);
return (
<ImmichLayout preview={customTemplate ? emailContent.toString() : 'You have been added to a shared album.'}>
{customTemplate && (
<Text className="m-0">
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
</Text>
)}
{!customTemplate && emailContent}
{cid && (
<Section className="flex justify-center my-0">
@ -43,7 +68,8 @@ export const AlbumInviteEmail = ({
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
</Text>
</ImmichLayout>
);
);
};
AlbumInviteEmail.PreviewProps = {
baseUrl: 'https://demo.immich.app',

View file

@ -3,9 +3,27 @@ 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) => (
<ImmichLayout preview="New media has been added to a shared album.">
export const AlbumUpdateEmail = ({
baseUrl,
albumName,
recipientName,
albumId,
cid,
customTemplate,
}: AlbumUpdateEmailProps) => {
const usableTemplateVariables = {
albumName,
recipientName,
albumId,
baseUrl,
};
const emailContent = customTemplate ? (
replaceTemplateTags(customTemplate, usableTemplateVariables)
) : (
<>
<Text className="m-0">
Hey <strong>{recipientName}</strong>!
</Text>
@ -14,6 +32,18 @@ export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, c
New media has been added to <strong>{albumName}</strong>,
<br /> check it out!
</Text>
</>
);
return (
<ImmichLayout preview={customTemplate ? emailContent.toString() : 'New media has been added to a shared album.'}>
{customTemplate && (
<Text className="m-0">
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
</Text>
)}
{!customTemplate && emailContent}
{cid && (
<Section className="flex justify-center my-0">
@ -37,13 +67,16 @@ export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, c
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
</Text>
</ImmichLayout>
);
);
};
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;

View file

@ -3,9 +3,20 @@ 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) => (
<ImmichLayout preview="You have been invited to a new Immich instance.">
export const WelcomeEmail = ({ baseUrl, displayName, username, password, customTemplate }: WelcomeEmailProps) => {
const usableTemplateVariables = {
displayName,
username,
password,
baseUrl,
};
const emailContent = customTemplate ? (
replaceTemplateTags(customTemplate, usableTemplateVariables)
) : (
<>
<Text className="m-0">
Hey <strong>{displayName}</strong>!
</Text>
@ -21,6 +32,20 @@ export const WelcomeEmail = ({ baseUrl, displayName, username, password }: Welco
</>
)}
</Text>
</>
);
return (
<ImmichLayout
preview={customTemplate ? emailContent.toString() : 'You have been invited to a new Immich instance.'}
>
{customTemplate && (
<Text className="m-0">
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
</Text>
)}
{!customTemplate && emailContent}
<Section className="flex justify-center my-6">
<ImmichButton href={`${baseUrl}/auth/login`}>Login</ImmichButton>
@ -32,7 +57,8 @@ export const WelcomeEmail = ({ baseUrl, displayName, username, password }: Welco
<Link href={baseUrl}>{baseUrl}</Link>
</Text>
</ImmichLayout>
);
);
};
WelcomeEmail.PreviewProps = {
baseUrl: 'https://demo.immich.app/auth/login',

View file

@ -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 = {

View file

@ -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);

View file

@ -55,22 +55,22 @@ export class NotificationRepository implements INotificationRepository {
}
}
private render({ template, data }: EmailRenderRequest): React.FunctionComponentElement<any> {
private render({ template, data, customTemplate }: EmailRenderRequest): React.FunctionComponentElement<any> {
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 });
}
}
}

View file

@ -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<JobName.NOTIFY_SIGNUP>) {
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({

View file

@ -190,6 +190,13 @@ const updatedConfig = Object.freeze<SystemConfig>({
},
},
},
templates: {
email: {
albumInviteTemplate: '',
welcomeTemplate: '',
albumUpdateTemplate: '',
},
},
});
describe(SystemConfigService.name, () => {

View file

@ -0,0 +1,5 @@
export const replaceTemplateTags = (template: string, variables: Record<string, string | undefined>) => {
return template.replaceAll(/{(.*?)}/g, (_, key) => {
return variables[key] || `{${key}}`;
});
};

2
web/package-lock.json generated
View file

@ -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",

View file

@ -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 @@
</div>
</SettingAccordion>
</div>
</form>
</div>
<TemplateSettings {defaultConfig} {config} {savedConfig} {onReset} {onSave} />
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['notifications'] })}
onSave={() => onSave({ notifications: config.notifications })}
onReset={(options) => onReset({ ...options, configKeys: ['notifications', 'templates'] })}
onSave={() => onSave({ notifications: config.notifications, templates: config.templates })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</form>
</div>
</div>

View file

@ -0,0 +1,131 @@
<script lang="ts">
import { type SystemConfigDto, type SystemConfigTemplateEmailsDto, getNotificationTemplate } from '@immich/sdk';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiEyeOutline } from '@mdi/js';
import { handleError } from '$lib/utils/handle-error';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, config = $bindable() }: Props = $props();
let htmlPreview = $state('');
let loadingPreview = $state(false);
const getTemplate = async (name: string, template: string) => {
try {
loadingPreview = true;
const result = await getNotificationTemplate({ name, templateDto: { template } });
htmlPreview = result.html;
} catch (error) {
handleError(error, 'Could not load template.');
} finally {
loadingPreview = false;
}
};
const closePreviewModal = () => {
htmlPreview = '';
};
const templateConfigs = [
{
label: $t('admin.template_email_welcome'),
templateKey: 'welcomeTemplate' as const,
descriptionTags: '{username}, {password}, {displayName}, {baseUrl}',
templateName: 'welcome',
},
{
label: $t('admin.template_email_invite_album'),
templateKey: 'albumInviteTemplate' as const,
descriptionTags: '{senderName}, {recipientName}, {albumId}, {albumName}, {baseUrl}',
templateName: 'album-invite',
},
{
label: $t('admin.template_email_update_album'),
templateKey: 'albumUpdateTemplate' as const,
descriptionTags: '{recipientName}, {albumId}, {albumName}, {baseUrl}',
templateName: 'album-update',
},
];
const isEdited = (templateKey: keyof SystemConfigTemplateEmailsDto) =>
config.templates.email[templateKey] !== savedConfig.templates.email[templateKey];
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit} class="mt-4">
<div class="flex flex-col gap-4">
<SettingAccordion
key="templates"
title={$t('admin.template_email_settings')}
subtitle={$t('admin.template_settings_description')}
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.template_email_if_empty">
{$t('admin.template_email_if_empty')}
</FormatMessage>
</p>
<hr />
{#if loadingPreview}
<LoadingSpinner />
{/if}
{#each templateConfigs as { label, templateKey, descriptionTags, templateName }}
<SettingTextarea
{label}
description={$t('admin.template_email_available_tags', { values: { tags: descriptionTags } })}
bind:value={config.templates.email[templateKey]}
isEdited={isEdited(templateKey)}
disabled={!config.notifications.smtp.enabled}
/>
<div class="flex justify-between">
<Button
size="sm"
onclick={() => getTemplate(templateName, config.templates.email[templateKey])}
title={$t('admin.template_email_preview')}
>
<Icon path={mdiEyeOutline} class="mr-1" />
{$t('admin.template_email_preview')}
</Button>
</div>
{/each}
</div>
</SettingAccordion>
</div>
{#if htmlPreview}
<FullScreenModal title={$t('admin.template_email_preview')} onClose={closePreviewModal} width="wide">
<div style="position:relative; width:100%; height:90vh; overflow: hidden">
<iframe
title={$t('admin.template_email_preview')}
srcdoc={htmlPreview}
style="width: 100%; height: 100%; border: none; overflow:hidden; position: absolute; top: 0; left: 0;"
></iframe>
</div>
</FullScreenModal>
{/if}
</form>
</div>
</div>