feat: Notification Email Templates (#13940)

pull/13981/head^2
Tim Van Onckelen 2024-12-04 21:26:02 +07: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 1145 additions and 114 deletions

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

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

@ -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",
@ -1325,4 +1335,4 @@
"zoom_image": "Zoom Image",
"timeline": "Timeline",
"total": "Total"
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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) => (
<ImmichLayout preview="You have been added to a shared album.">
<Text className="m-0">
Hey <strong>{recipientName}</strong>!
</Text>
<Text>
{senderName} has added you to the album <strong>{albumName}</strong>.
</Text>
{cid && (
<Section className="flex justify-center my-0">
<Img
className="max-w-[300px] w-full rounded-lg"
src={`cid:${cid}`}
style={{
boxShadow: 'rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px',
}}
/>
customTemplate,
}: AlbumInviteEmailProps) => {
const variables = {
albumName,
recipientName,
senderName,
albumId,
baseUrl,
};
const emailContent = customTemplate ? (
replaceTemplateTags(customTemplate, variables)
) : (
<>
<Text className="m-0">
Hey <strong>{recipientName}</strong>!
</Text>
<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">
<Img
className="max-w-[300px] w-full rounded-lg"
src={`cid:${cid}`}
style={{
boxShadow: 'rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px',
}}
/>
</Section>
)}
<Section className="flex justify-center my-6">
<ImmichButton href={`${baseUrl}/albums/${albumId}`}>View Album</ImmichButton>
</Section>
)}
<Section className="flex justify-center my-6">
<ImmichButton href={`${baseUrl}/albums/${albumId}`}>View Album</ImmichButton>
</Section>
<Text className="text-xs">
If you cannot click the button use the link below to view the album.
<br />
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
</Text>
</ImmichLayout>
);
<Text className="text-xs">
If you cannot click the button use the link below to view the album.
<br />
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
</Text>
</ImmichLayout>
);
};
AlbumInviteEmail.PreviewProps = {
baseUrl: 'https://demo.immich.app',

@ -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) => (
<ImmichLayout preview="New media has been added to a shared album.">
<Text className="m-0">
Hey <strong>{recipientName}</strong>!
</Text>
<Text>
New media has been added to <strong>{albumName}</strong>,
<br /> check it out!
</Text>
{cid && (
<Section className="flex justify-center my-0">
<Img
className="max-w-[300px] w-full rounded-lg"
src={`cid:${cid}`}
style={{
boxShadow: 'rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px',
}}
/>
</Section>
)}
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>
<Text>
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>
)}
<Section className="flex justify-center my-6">
<ImmichButton href={`${baseUrl}/albums/${albumId}`}>View Album</ImmichButton>
</Section>
{!customTemplate && emailContent}
{cid && (
<Section className="flex justify-center my-0">
<Img
className="max-w-[300px] w-full rounded-lg"
src={`cid:${cid}`}
style={{
boxShadow: 'rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px',
}}
/>
</Section>
)}
<Section className="flex justify-center my-6">
<ImmichButton href={`${baseUrl}/albums/${albumId}`}>View Album</ImmichButton>
</Section>
<Text className="text-xs">
If you cannot click the button use the link below to view the album.
<br />
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
</Text>
</ImmichLayout>
);
<Text className="text-xs">
If you cannot click the button use the link below to view the album.
<br />
<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;

@ -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) => (
<ImmichLayout preview="You have been invited to a new Immich instance.">
<Text className="m-0">
Hey <strong>{displayName}</strong>!
</Text>
<Text>A new account has been created for you.</Text>
<Text>
<strong>Username</strong>: {username}
{password && (
<>
<br />
<strong>Password</strong>: {password}
</>
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>
<Text>A new account has been created for you.</Text>
<Text>
<strong>Username</strong>: {username}
{password && (
<>
<br />
<strong>Password</strong>: {password}
</>
)}
</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>
)}
</Text>
<Section className="flex justify-center my-6">
<ImmichButton href={`${baseUrl}/auth/login`}>Login</ImmichButton>
</Section>
<Text className="text-xs">
If you cannot click the button use the link below to proceed with first login.
<br />
<Link href={baseUrl}>{baseUrl}</Link>
</Text>
</ImmichLayout>
);
{!customTemplate && emailContent}
<Section className="flex justify-center my-6">
<ImmichButton href={`${baseUrl}/auth/login`}>Login</ImmichButton>
</Section>
<Text className="text-xs">
If you cannot click the button use the link below to proceed with first login.
<br />
<Link href={baseUrl}>{baseUrl}</Link>
</Text>
</ImmichLayout>
);
};
WelcomeEmail.PreviewProps = {
baseUrl: 'https://demo.immich.app/auth/login',

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

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

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

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

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

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

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

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

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