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: 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" /> <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) 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 ## Server Settings
### External Domain ### External Domain

View File

@ -252,6 +252,16 @@
"storage_template_user_label": "<code>{label}</code> is the user's Storage Label", "storage_template_user_label": "<code>{label}</code> is the user's Storage Label",
"system_settings": "System Settings", "system_settings": "System Settings",
"tag_cleanup_job": "Tag cleanup", "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": "Custom CSS",
"theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.", "theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.",
"theme_settings": "Theme Settings", "theme_settings": "Theme Settings",
@ -1325,4 +1335,4 @@
"zoom_image": "Zoom Image", "zoom_image": "Zoom Image",
"timeline": "Timeline", "timeline": "Timeline",
"total": "Total" "total": "Total"
} }

View File

@ -247,6 +247,16 @@
"storage_template_user_label": "<code>{label}</code> is het opslaglabel van de gebruiker", "storage_template_user_label": "<code>{label}</code> is het opslaglabel van de gebruiker",
"system_settings": "Systeeminstellingen", "system_settings": "Systeeminstellingen",
"tag_cleanup_job": "Tag opschoning", "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": "Aangepaste CSS",
"theme_custom_css_settings_description": "Met Cascading Style Sheets kan het ontwerp van Immich worden aangepast.", "theme_custom_css_settings_description": "Met Cascading Style Sheets kan het ontwerp van Immich worden aangepast.",
"theme_settings": "Thema instellingen", "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* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets |
*MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories | *MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories |
*MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} | *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 | *NotificationsApi* | [**sendTestEmail**](doc//NotificationsApi.md#sendtestemail) | **POST** /notifications/test-email |
*OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback | *OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback |
*OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link | *OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link |
@ -436,7 +437,9 @@ Class | Method | HTTP request | Description
- [SystemConfigSmtpDto](doc//SystemConfigSmtpDto.md) - [SystemConfigSmtpDto](doc//SystemConfigSmtpDto.md)
- [SystemConfigSmtpTransportDto](doc//SystemConfigSmtpTransportDto.md) - [SystemConfigSmtpTransportDto](doc//SystemConfigSmtpTransportDto.md)
- [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md) - [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md)
- [SystemConfigTemplateEmailsDto](doc//SystemConfigTemplateEmailsDto.md)
- [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md) - [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md)
- [SystemConfigTemplatesDto](doc//SystemConfigTemplatesDto.md)
- [SystemConfigThemeDto](doc//SystemConfigThemeDto.md) - [SystemConfigThemeDto](doc//SystemConfigThemeDto.md)
- [SystemConfigTrashDto](doc//SystemConfigTrashDto.md) - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md)
- [SystemConfigUserDto](doc//SystemConfigUserDto.md) - [SystemConfigUserDto](doc//SystemConfigUserDto.md)
@ -448,6 +451,8 @@ Class | Method | HTTP request | Description
- [TagUpsertDto](doc//TagUpsertDto.md) - [TagUpsertDto](doc//TagUpsertDto.md)
- [TagsResponse](doc//TagsResponse.md) - [TagsResponse](doc//TagsResponse.md)
- [TagsUpdate](doc//TagsUpdate.md) - [TagsUpdate](doc//TagsUpdate.md)
- [TemplateDto](doc//TemplateDto.md)
- [TemplateResponseDto](doc//TemplateResponseDto.md)
- [TestEmailResponseDto](doc//TestEmailResponseDto.md) - [TestEmailResponseDto](doc//TestEmailResponseDto.md)
- [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md)
- [TimeBucketSize](doc//TimeBucketSize.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_dto.dart';
part 'model/system_config_smtp_transport_dto.dart'; part 'model/system_config_smtp_transport_dto.dart';
part 'model/system_config_storage_template_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_template_storage_option_dto.dart';
part 'model/system_config_templates_dto.dart';
part 'model/system_config_theme_dto.dart'; part 'model/system_config_theme_dto.dart';
part 'model/system_config_trash_dto.dart'; part 'model/system_config_trash_dto.dart';
part 'model/system_config_user_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/tag_upsert_dto.dart';
part 'model/tags_response.dart'; part 'model/tags_response.dart';
part 'model/tags_update.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/test_email_response_dto.dart';
part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_response_dto.dart';
part 'model/time_bucket_size.dart'; part 'model/time_bucket_size.dart';

View File

@ -16,6 +16,58 @@ class NotificationsApi {
final ApiClient apiClient; 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]. /// Performs an HTTP 'POST /notifications/test-email' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///

View File

@ -554,8 +554,12 @@ class ApiClient {
return SystemConfigSmtpTransportDto.fromJson(value); return SystemConfigSmtpTransportDto.fromJson(value);
case 'SystemConfigStorageTemplateDto': case 'SystemConfigStorageTemplateDto':
return SystemConfigStorageTemplateDto.fromJson(value); return SystemConfigStorageTemplateDto.fromJson(value);
case 'SystemConfigTemplateEmailsDto':
return SystemConfigTemplateEmailsDto.fromJson(value);
case 'SystemConfigTemplateStorageOptionDto': case 'SystemConfigTemplateStorageOptionDto':
return SystemConfigTemplateStorageOptionDto.fromJson(value); return SystemConfigTemplateStorageOptionDto.fromJson(value);
case 'SystemConfigTemplatesDto':
return SystemConfigTemplatesDto.fromJson(value);
case 'SystemConfigThemeDto': case 'SystemConfigThemeDto':
return SystemConfigThemeDto.fromJson(value); return SystemConfigThemeDto.fromJson(value);
case 'SystemConfigTrashDto': case 'SystemConfigTrashDto':
@ -578,6 +582,10 @@ class ApiClient {
return TagsResponse.fromJson(value); return TagsResponse.fromJson(value);
case 'TagsUpdate': case 'TagsUpdate':
return TagsUpdate.fromJson(value); return TagsUpdate.fromJson(value);
case 'TemplateDto':
return TemplateDto.fromJson(value);
case 'TemplateResponseDto':
return TemplateResponseDto.fromJson(value);
case 'TestEmailResponseDto': case 'TestEmailResponseDto':
return TestEmailResponseDto.fromJson(value); return TestEmailResponseDto.fromJson(value);
case 'TimeBucketResponseDto': case 'TimeBucketResponseDto':

View File

@ -29,6 +29,7 @@ class SystemConfigDto {
required this.reverseGeocoding, required this.reverseGeocoding,
required this.server, required this.server,
required this.storageTemplate, required this.storageTemplate,
required this.templates,
required this.theme, required this.theme,
required this.trash, required this.trash,
required this.user, required this.user,
@ -66,6 +67,8 @@ class SystemConfigDto {
SystemConfigStorageTemplateDto storageTemplate; SystemConfigStorageTemplateDto storageTemplate;
SystemConfigTemplatesDto templates;
SystemConfigThemeDto theme; SystemConfigThemeDto theme;
SystemConfigTrashDto trash; SystemConfigTrashDto trash;
@ -90,6 +93,7 @@ class SystemConfigDto {
other.reverseGeocoding == reverseGeocoding && other.reverseGeocoding == reverseGeocoding &&
other.server == server && other.server == server &&
other.storageTemplate == storageTemplate && other.storageTemplate == storageTemplate &&
other.templates == templates &&
other.theme == theme && other.theme == theme &&
other.trash == trash && other.trash == trash &&
other.user == user; other.user == user;
@ -113,12 +117,13 @@ class SystemConfigDto {
(reverseGeocoding.hashCode) + (reverseGeocoding.hashCode) +
(server.hashCode) + (server.hashCode) +
(storageTemplate.hashCode) + (storageTemplate.hashCode) +
(templates.hashCode) +
(theme.hashCode) + (theme.hashCode) +
(trash.hashCode) + (trash.hashCode) +
(user.hashCode); (user.hashCode);
@override @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() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -138,6 +143,7 @@ class SystemConfigDto {
json[r'reverseGeocoding'] = this.reverseGeocoding; json[r'reverseGeocoding'] = this.reverseGeocoding;
json[r'server'] = this.server; json[r'server'] = this.server;
json[r'storageTemplate'] = this.storageTemplate; json[r'storageTemplate'] = this.storageTemplate;
json[r'templates'] = this.templates;
json[r'theme'] = this.theme; json[r'theme'] = this.theme;
json[r'trash'] = this.trash; json[r'trash'] = this.trash;
json[r'user'] = this.user; json[r'user'] = this.user;
@ -169,6 +175,7 @@ class SystemConfigDto {
reverseGeocoding: SystemConfigReverseGeocodingDto.fromJson(json[r'reverseGeocoding'])!, reverseGeocoding: SystemConfigReverseGeocodingDto.fromJson(json[r'reverseGeocoding'])!,
server: SystemConfigServerDto.fromJson(json[r'server'])!, server: SystemConfigServerDto.fromJson(json[r'server'])!,
storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!, storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!,
templates: SystemConfigTemplatesDto.fromJson(json[r'templates'])!,
theme: SystemConfigThemeDto.fromJson(json[r'theme'])!, theme: SystemConfigThemeDto.fromJson(json[r'theme'])!,
trash: SystemConfigTrashDto.fromJson(json[r'trash'])!, trash: SystemConfigTrashDto.fromJson(json[r'trash'])!,
user: SystemConfigUserDto.fromJson(json[r'user'])!, user: SystemConfigUserDto.fromJson(json[r'user'])!,
@ -235,6 +242,7 @@ class SystemConfigDto {
'reverseGeocoding', 'reverseGeocoding',
'server', 'server',
'storageTemplate', 'storageTemplate',
'templates',
'theme', 'theme',
'trash', 'trash',
'user', '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": { "/notifications/test-email": {
"post": { "post": {
"operationId": "sendTestEmail", "operationId": "sendTestEmail",
@ -11538,6 +11589,9 @@
"storageTemplate": { "storageTemplate": {
"$ref": "#/components/schemas/SystemConfigStorageTemplateDto" "$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
}, },
"templates": {
"$ref": "#/components/schemas/SystemConfigTemplatesDto"
},
"theme": { "theme": {
"$ref": "#/components/schemas/SystemConfigThemeDto" "$ref": "#/components/schemas/SystemConfigThemeDto"
}, },
@ -11565,6 +11619,7 @@
"reverseGeocoding", "reverseGeocoding",
"server", "server",
"storageTemplate", "storageTemplate",
"templates",
"theme", "theme",
"trash", "trash",
"user" "user"
@ -12111,6 +12166,25 @@
], ],
"type": "object" "type": "object"
}, },
"SystemConfigTemplateEmailsDto": {
"properties": {
"albumInviteTemplate": {
"type": "string"
},
"albumUpdateTemplate": {
"type": "string"
},
"welcomeTemplate": {
"type": "string"
}
},
"required": [
"albumInviteTemplate",
"albumUpdateTemplate",
"welcomeTemplate"
],
"type": "object"
},
"SystemConfigTemplateStorageOptionDto": { "SystemConfigTemplateStorageOptionDto": {
"properties": { "properties": {
"dayOptions": { "dayOptions": {
@ -12174,6 +12248,17 @@
], ],
"type": "object" "type": "object"
}, },
"SystemConfigTemplatesDto": {
"properties": {
"email": {
"$ref": "#/components/schemas/SystemConfigTemplateEmailsDto"
}
},
"required": [
"email"
],
"type": "object"
},
"SystemConfigThemeDto": { "SystemConfigThemeDto": {
"properties": { "properties": {
"customCss": { "customCss": {
@ -12352,6 +12437,32 @@
}, },
"type": "object" "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": { "TestEmailResponseDto": {
"properties": { "properties": {
"messageId": { "messageId": {

View File

@ -634,6 +634,13 @@ export type MemoryUpdateDto = {
memoryAt?: string; memoryAt?: string;
seenAt?: string; seenAt?: string;
}; };
export type TemplateDto = {
template: string;
};
export type TemplateResponseDto = {
html: string;
name: string;
};
export type SystemConfigSmtpTransportDto = { export type SystemConfigSmtpTransportDto = {
host: string; host: string;
ignoreCert: boolean; ignoreCert: boolean;
@ -1232,6 +1239,14 @@ export type SystemConfigStorageTemplateDto = {
hashVerificationEnabled: boolean; hashVerificationEnabled: boolean;
template: string; template: string;
}; };
export type SystemConfigTemplateEmailsDto = {
albumInviteTemplate: string;
albumUpdateTemplate: string;
welcomeTemplate: string;
};
export type SystemConfigTemplatesDto = {
email: SystemConfigTemplateEmailsDto;
};
export type SystemConfigThemeDto = { export type SystemConfigThemeDto = {
customCss: string; customCss: string;
}; };
@ -1259,6 +1274,7 @@ export type SystemConfigDto = {
reverseGeocoding: SystemConfigReverseGeocodingDto; reverseGeocoding: SystemConfigReverseGeocodingDto;
server: SystemConfigServerDto; server: SystemConfigServerDto;
storageTemplate: SystemConfigStorageTemplateDto; storageTemplate: SystemConfigStorageTemplateDto;
templates: SystemConfigTemplatesDto;
theme: SystemConfigThemeDto; theme: SystemConfigThemeDto;
trash: SystemConfigTrashDto; trash: SystemConfigTrashDto;
user: SystemConfigUserDto; user: SystemConfigUserDto;
@ -2227,6 +2243,19 @@ export function addMemoryAssets({ id, bulkIdsDto }: {
body: 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 }: { export function sendTestEmail({ systemConfigSmtpDto }: {
systemConfigSmtpDto: SystemConfigSmtpDto; systemConfigSmtpDto: SystemConfigSmtpDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {

View File

@ -146,6 +146,13 @@ export interface SystemConfig {
}; };
}; };
}; };
templates: {
email: {
welcomeTemplate: string;
albumInviteTemplate: string;
albumUpdateTemplate: string;
};
};
server: { server: {
externalDomain: string; externalDomain: string;
loginPageMessage: string; loginPageMessage: string;
@ -313,6 +320,13 @@ export const defaults = Object.freeze<SystemConfig>({
}, },
}, },
}, },
templates: {
email: {
welcomeTemplate: '',
albumInviteTemplate: '',
albumUpdateTemplate: '',
},
},
user: { user: {
deleteDelay: 7, 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 { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto'; 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 { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { EmailTemplate } from 'src/interfaces/notification.interface';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { NotificationService } from 'src/services/notification.service'; import { NotificationService } from 'src/services/notification.service';
@ -17,4 +18,15 @@ export class NotificationController {
sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise<TestEmailResponseDto> { sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise<TestEmailResponseDto> {
return this.service.sendTestEmail(auth.user.id, dto); 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 { export class TestEmailResponseDto {
messageId!: string; 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; smtp!: SystemConfigSmtpDto;
} }
class SystemConfigTemplateEmailsDto {
@IsString()
albumInviteTemplate!: string;
@IsString()
welcomeTemplate!: string;
@IsString()
albumUpdateTemplate!: string;
}
class SystemConfigTemplatesDto {
@Type(() => SystemConfigTemplateEmailsDto)
@ValidateNested()
@IsObject()
email!: SystemConfigTemplateEmailsDto;
}
class SystemConfigStorageTemplateDto { class SystemConfigStorageTemplateDto {
@ValidateBoolean() @ValidateBoolean()
enabled!: boolean; enabled!: boolean;
@ -636,6 +654,11 @@ export class SystemConfigDto implements SystemConfig {
@IsObject() @IsObject()
notifications!: SystemConfigNotificationsDto; notifications!: SystemConfigNotificationsDto;
@Type(() => SystemConfigTemplatesDto)
@ValidateNested()
@IsObject()
templates!: SystemConfigTemplatesDto;
@Type(() => SystemConfigServerDto) @Type(() => SystemConfigServerDto)
@ValidateNested() @ValidateNested()
@IsObject() @IsObject()

View File

@ -3,6 +3,7 @@ import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component'; import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout'; import ImmichLayout from 'src/emails/components/immich.layout';
import { AlbumInviteEmailProps } from 'src/interfaces/notification.interface'; import { AlbumInviteEmailProps } from 'src/interfaces/notification.interface';
import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const AlbumInviteEmail = ({ export const AlbumInviteEmail = ({
baseUrl, baseUrl,
@ -11,39 +12,64 @@ export const AlbumInviteEmail = ({
senderName, senderName,
albumId, albumId,
cid, cid,
}: AlbumInviteEmailProps) => ( customTemplate,
<ImmichLayout preview="You have been added to a shared album."> }: AlbumInviteEmailProps) => {
<Text className="m-0"> const variables = {
Hey <strong>{recipientName}</strong>! albumName,
</Text> recipientName,
senderName,
albumId,
baseUrl,
};
<Text> const emailContent = customTemplate ? (
{senderName} has added you to the album <strong>{albumName}</strong>. replaceTemplateTags(customTemplate, variables)
</Text> ) : (
<>
<Text className="m-0">
Hey <strong>{recipientName}</strong>!
</Text>
{cid && ( <Text>
<Section className="flex justify-center my-0"> {senderName} has added you to the album <strong>{albumName}</strong>.
<Img </Text>
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', 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>
)}
<Section className="flex justify-center my-6"> <Text className="text-xs">
<ImmichButton href={`${baseUrl}/albums/${albumId}`}>View Album</ImmichButton> If you cannot click the button use the link below to view the album.
</Section> <br />
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
<Text className="text-xs"> </Text>
If you cannot click the button use the link below to view the album. </ImmichLayout>
<br /> );
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link> };
</Text>
</ImmichLayout>
);
AlbumInviteEmail.PreviewProps = { AlbumInviteEmail.PreviewProps = {
baseUrl: 'https://demo.immich.app', baseUrl: 'https://demo.immich.app',

View File

@ -3,47 +3,80 @@ import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component'; import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout'; import ImmichLayout from 'src/emails/components/immich.layout';
import { AlbumUpdateEmailProps } from 'src/interfaces/notification.interface'; import { AlbumUpdateEmailProps } from 'src/interfaces/notification.interface';
import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, cid }: AlbumUpdateEmailProps) => ( export const AlbumUpdateEmail = ({
<ImmichLayout preview="New media has been added to a shared album."> baseUrl,
<Text className="m-0"> albumName,
Hey <strong>{recipientName}</strong>! recipientName,
</Text> albumId,
cid,
customTemplate,
}: AlbumUpdateEmailProps) => {
const usableTemplateVariables = {
albumName,
recipientName,
albumId,
baseUrl,
};
<Text> const emailContent = customTemplate ? (
New media has been added to <strong>{albumName}</strong>, replaceTemplateTags(customTemplate, usableTemplateVariables)
<br /> check it out! ) : (
</Text> <>
<Text className="m-0">
Hey <strong>{recipientName}</strong>!
</Text>
{cid && ( <Text>
<Section className="flex justify-center my-0"> New media has been added to <strong>{albumName}</strong>,
<Img <br /> check it out!
className="max-w-[300px] w-full rounded-lg" </Text>
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',
}} 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">
<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>
)}
<Section className="flex justify-center my-6"> <Text className="text-xs">
<ImmichButton href={`${baseUrl}/albums/${albumId}`}>View Album</ImmichButton> If you cannot click the button use the link below to view the album.
</Section> <br />
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
<Text className="text-xs"> </Text>
If you cannot click the button use the link below to view the album. </ImmichLayout>
<br /> );
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link> };
</Text>
</ImmichLayout>
);
AlbumUpdateEmail.PreviewProps = { AlbumUpdateEmail.PreviewProps = {
baseUrl: 'https://demo.immich.app', baseUrl: 'https://demo.immich.app',
albumName: 'Trip to Europe', albumName: 'Trip to Europe',
albumId: 'b63f6dae-e1c9-401b-9a85-9dbbf5612539', albumId: 'b63f6dae-e1c9-401b-9a85-9dbbf5612539',
recipientName: 'Alan Turing', recipientName: 'Alan Turing',
cid: '',
customTemplate: '',
} as AlbumUpdateEmailProps; } as AlbumUpdateEmailProps;
export default AlbumUpdateEmail; export default AlbumUpdateEmail;

View File

@ -3,36 +3,62 @@ import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component'; import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout'; import ImmichLayout from 'src/emails/components/immich.layout';
import { WelcomeEmailProps } from 'src/interfaces/notification.interface'; import { WelcomeEmailProps } from 'src/interfaces/notification.interface';
import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const WelcomeEmail = ({ baseUrl, displayName, username, password }: WelcomeEmailProps) => ( export const WelcomeEmail = ({ baseUrl, displayName, username, password, customTemplate }: WelcomeEmailProps) => {
<ImmichLayout preview="You have been invited to a new Immich instance."> const usableTemplateVariables = {
<Text className="m-0"> displayName,
Hey <strong>{displayName}</strong>! username,
</Text> password,
baseUrl,
};
<Text>A new account has been created for you.</Text> const emailContent = customTemplate ? (
replaceTemplateTags(customTemplate, usableTemplateVariables)
) : (
<>
<Text className="m-0">
Hey <strong>{displayName}</strong>!
</Text>
<Text> <Text>A new account has been created for you.</Text>
<strong>Username</strong>: {username}
{password && ( <Text>
<> <strong>Username</strong>: {username}
<br /> {password && (
<strong>Password</strong>: {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"> {!customTemplate && emailContent}
<ImmichButton href={`${baseUrl}/auth/login`}>Login</ImmichButton>
</Section>
<Text className="text-xs"> <Section className="flex justify-center my-6">
If you cannot click the button use the link below to proceed with first login. <ImmichButton href={`${baseUrl}/auth/login`}>Login</ImmichButton>
<br /> </Section>
<Link href={baseUrl}>{baseUrl}</Link>
</Text> <Text className="text-xs">
</ImmichLayout> 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 = { WelcomeEmail.PreviewProps = {
baseUrl: 'https://demo.immich.app/auth/login', baseUrl: 'https://demo.immich.app/auth/login',

View File

@ -39,6 +39,7 @@ export enum EmailTemplate {
interface BaseEmailProps { interface BaseEmailProps {
baseUrl: string; baseUrl: string;
customTemplate?: string;
} }
export interface TestEmailProps extends BaseEmailProps { export interface TestEmailProps extends BaseEmailProps {
@ -70,18 +71,22 @@ export type EmailRenderRequest =
| { | {
template: EmailTemplate.TEST_EMAIL; template: EmailTemplate.TEST_EMAIL;
data: TestEmailProps; data: TestEmailProps;
customTemplate: string;
} }
| { | {
template: EmailTemplate.WELCOME; template: EmailTemplate.WELCOME;
data: WelcomeEmailProps; data: WelcomeEmailProps;
customTemplate: string;
} }
| { | {
template: EmailTemplate.ALBUM_INVITE; template: EmailTemplate.ALBUM_INVITE;
data: AlbumInviteEmailProps; data: AlbumInviteEmailProps;
customTemplate: string;
} }
| { | {
template: EmailTemplate.ALBUM_UPDATE; template: EmailTemplate.ALBUM_UPDATE;
data: AlbumUpdateEmailProps; data: AlbumUpdateEmailProps;
customTemplate: string;
}; };
export type SendEmailResponse = { export type SendEmailResponse = {

View File

@ -21,6 +21,7 @@ describe(NotificationRepository.name, () => {
const request: EmailRenderRequest = { const request: EmailRenderRequest = {
template: EmailTemplate.TEST_EMAIL, template: EmailTemplate.TEST_EMAIL,
data: { displayName: 'Alen Turing', baseUrl: 'http://localhost' }, data: { displayName: 'Alen Turing', baseUrl: 'http://localhost' },
customTemplate: '',
}; };
const result = await sut.renderEmail(request); const result = await sut.renderEmail(request);
@ -33,6 +34,7 @@ describe(NotificationRepository.name, () => {
const request: EmailRenderRequest = { const request: EmailRenderRequest = {
template: EmailTemplate.WELCOME, template: EmailTemplate.WELCOME,
data: { displayName: 'Alen Turing', username: 'turing', baseUrl: 'http://localhost' }, data: { displayName: 'Alen Turing', username: 'turing', baseUrl: 'http://localhost' },
customTemplate: '',
}; };
const result = await sut.renderEmail(request); const result = await sut.renderEmail(request);
@ -51,6 +53,7 @@ describe(NotificationRepository.name, () => {
recipientName: 'Jane', recipientName: 'Jane',
baseUrl: 'http://localhost', baseUrl: 'http://localhost',
}, },
customTemplate: '',
}; };
const result = await sut.renderEmail(request); const result = await sut.renderEmail(request);
@ -63,6 +66,7 @@ describe(NotificationRepository.name, () => {
const request: EmailRenderRequest = { const request: EmailRenderRequest = {
template: EmailTemplate.ALBUM_UPDATE, template: EmailTemplate.ALBUM_UPDATE,
data: { albumName: 'Holiday', albumId: '123', recipientName: 'Jane', baseUrl: 'http://localhost' }, data: { albumName: 'Holiday', albumId: '123', recipientName: 'Jane', baseUrl: 'http://localhost' },
customTemplate: '',
}; };
const result = await sut.renderEmail(request); 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) { switch (template) {
case EmailTemplate.TEST_EMAIL: { case EmailTemplate.TEST_EMAIL: {
return React.createElement(TestEmail, data); return React.createElement(TestEmail, { ...data, customTemplate });
} }
case EmailTemplate.WELCOME: { case EmailTemplate.WELCOME: {
return React.createElement(WelcomeEmail, data); return React.createElement(WelcomeEmail, { ...data, customTemplate });
} }
case EmailTemplate.ALBUM_INVITE: { case EmailTemplate.ALBUM_INVITE: {
return React.createElement(AlbumInviteEmail, data); return React.createElement(AlbumInviteEmail, { ...data, customTemplate });
} }
case EmailTemplate.ALBUM_UPDATE: { 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); 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 }); const user = await this.userRepository.get(id, { withDeleted: false });
if (!user) { if (!user) {
throw new Error('User not found'); throw new Error('User not found');
@ -160,8 +160,8 @@ export class NotificationService extends BaseService {
baseUrl: getExternalDomain(server, port), baseUrl: getExternalDomain(server, port),
displayName: user.name, displayName: user.name,
}, },
customTemplate: tempTemplate!,
}); });
const { messageId } = await this.notificationRepository.sendEmail({ const { messageId } = await this.notificationRepository.sendEmail({
to: user.email, to: user.email,
subject: 'Test email from Immich', subject: 'Test email from Immich',
@ -175,6 +175,69 @@ export class NotificationService extends BaseService {
return { messageId }; 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 }) @OnJob({ name: JobName.NOTIFY_SIGNUP, queue: QueueName.NOTIFICATION })
async handleUserSignup({ id, tempPassword }: JobOf<JobName.NOTIFY_SIGNUP>) { async handleUserSignup({ id, tempPassword }: JobOf<JobName.NOTIFY_SIGNUP>) {
const user = await this.userRepository.get(id, { withDeleted: false }); const user = await this.userRepository.get(id, { withDeleted: false });
@ -182,7 +245,7 @@ export class NotificationService extends BaseService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const { server } = await this.getConfig({ withCache: true }); const { server, templates } = await this.getConfig({ withCache: true });
const { port } = this.configRepository.getEnv(); const { port } = this.configRepository.getEnv();
const { html, text } = await this.notificationRepository.renderEmail({ const { html, text } = await this.notificationRepository.renderEmail({
template: EmailTemplate.WELCOME, template: EmailTemplate.WELCOME,
@ -192,6 +255,7 @@ export class NotificationService extends BaseService {
username: user.email, username: user.email,
password: tempPassword, password: tempPassword,
}, },
customTemplate: templates.email.welcomeTemplate,
}); });
await this.jobRepository.queue({ await this.jobRepository.queue({
@ -227,7 +291,7 @@ export class NotificationService extends BaseService {
const attachment = await this.getAlbumThumbnailAttachment(album); 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 { port } = this.configRepository.getEnv();
const { html, text } = await this.notificationRepository.renderEmail({ const { html, text } = await this.notificationRepository.renderEmail({
template: EmailTemplate.ALBUM_INVITE, template: EmailTemplate.ALBUM_INVITE,
@ -239,6 +303,7 @@ export class NotificationService extends BaseService {
recipientName: recipient.name, recipientName: recipient.name,
cid: attachment ? attachment.cid : undefined, cid: attachment ? attachment.cid : undefined,
}, },
customTemplate: templates.email.albumInviteTemplate,
}); });
await this.jobRepository.queue({ await this.jobRepository.queue({
@ -273,7 +338,7 @@ export class NotificationService extends BaseService {
); );
const attachment = await this.getAlbumThumbnailAttachment(album); 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 { port } = this.configRepository.getEnv();
for (const recipient of recipients) { for (const recipient of recipients) {
@ -297,6 +362,7 @@ export class NotificationService extends BaseService {
recipientName: recipient.name, recipientName: recipient.name,
cid: attachment ? attachment.cid : undefined, cid: attachment ? attachment.cid : undefined,
}, },
customTemplate: templates.email.albumUpdateTemplate,
}); });
await this.jobRepository.queue({ await this.jobRepository.queue({

View File

@ -190,6 +190,13 @@ const updatedConfig = Object.freeze<SystemConfig>({
}, },
}, },
}, },
templates: {
email: {
albumInviteTemplate: '',
welcomeTemplate: '',
albumUpdateTemplate: '',
},
},
}); });
describe(SystemConfigService.name, () => { 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", "justified-layout": "^4.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"socket.io-client": "^4.7.5", "socket.io-client": "~4.7.5",
"svelte-gestures": "^5.0.4", "svelte-gestures": "^5.0.4",
"svelte-i18n": "^4.0.1", "svelte-i18n": "^4.0.1",
"svelte-local-storage-store": "^0.6.4", "svelte-local-storage-store": "^0.6.4",

View File

@ -17,6 +17,7 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { SettingInputFieldType } from '$lib/constants'; import { SettingInputFieldType } from '$lib/constants';
import TemplateSettings from '$lib/components/admin-page/settings/template-settings/template-settings.svelte';
interface Props { interface Props {
savedConfig: SystemConfigDto; savedConfig: SystemConfigDto;
@ -162,13 +163,14 @@
</div> </div>
</SettingAccordion> </SettingAccordion>
</div> </div>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['notifications'] })}
onSave={() => onSave({ notifications: config.notifications })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</form> </form>
</div> </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> </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>