mirror of
https://github.com/immich-app/immich.git
synced 2025-03-01 15:11:21 +01:00
feat: Notification Email Templates (#13940)
This commit is contained in:
parent
4bf1b84cc2
commit
292182fa7f
32 changed files with 1136 additions and 105 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" />
|
||||
|
|
BIN
docs/docs/administration/img/user-notifications-templates.png
Normal file
BIN
docs/docs/administration/img/user-notifications-templates.png
Normal file
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
|
||||
|
|
10
i18n/en.json
10
i18n/en.json
|
@ -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",
|
||||
|
|
10
i18n/nl.json
10
i18n/nl.json
|
@ -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",
|
||||
|
|
5
mobile/openapi/README.md
generated
5
mobile/openapi/README.md
generated
|
@ -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)
|
||||
|
|
4
mobile/openapi/lib/api.dart
generated
4
mobile/openapi/lib/api.dart
generated
|
@ -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';
|
||||
|
|
52
mobile/openapi/lib/api/notifications_api.dart
generated
52
mobile/openapi/lib/api/notifications_api.dart
generated
|
@ -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:
|
||||
///
|
||||
|
|
8
mobile/openapi/lib/api_client.dart
generated
8
mobile/openapi/lib/api_client.dart
generated
|
@ -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':
|
||||
|
|
10
mobile/openapi/lib/model/system_config_dto.dart
generated
10
mobile/openapi/lib/model/system_config_dto.dart
generated
|
@ -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',
|
||||
|
|
115
mobile/openapi/lib/model/system_config_template_emails_dto.dart
generated
Normal file
115
mobile/openapi/lib/model/system_config_template_emails_dto.dart
generated
Normal 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',
|
||||
};
|
||||
}
|
||||
|
99
mobile/openapi/lib/model/system_config_templates_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/system_config_templates_dto.dart
generated
Normal 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',
|
||||
};
|
||||
}
|
||||
|
99
mobile/openapi/lib/model/template_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/template_dto.dart
generated
Normal 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',
|
||||
};
|
||||
}
|
||||
|
107
mobile/openapi/lib/model/template_response_dto.dart
generated
Normal file
107
mobile/openapi/lib/model/template_response_dto.dart
generated
Normal 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',
|
||||
};
|
||||
}
|
||||
|
|
@ -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,8 +12,20 @@ export const AlbumInviteEmail = ({
|
|||
senderName,
|
||||
albumId,
|
||||
cid,
|
||||
}: AlbumInviteEmailProps) => (
|
||||
<ImmichLayout preview="You have been added to a shared album.">
|
||||
customTemplate,
|
||||
}: AlbumInviteEmailProps) => {
|
||||
const variables = {
|
||||
albumName,
|
||||
recipientName,
|
||||
senderName,
|
||||
albumId,
|
||||
baseUrl,
|
||||
};
|
||||
|
||||
const emailContent = customTemplate ? (
|
||||
replaceTemplateTags(customTemplate, variables)
|
||||
) : (
|
||||
<>
|
||||
<Text className="m-0">
|
||||
Hey <strong>{recipientName}</strong>!
|
||||
</Text>
|
||||
|
@ -20,6 +33,18 @@ export const AlbumInviteEmail = ({
|
|||
<Text>
|
||||
{senderName} has added you to the album <strong>{albumName}</strong>.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ImmichLayout preview={customTemplate ? emailContent.toString() : 'You have been added to a shared album.'}>
|
||||
{customTemplate && (
|
||||
<Text className="m-0">
|
||||
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!customTemplate && emailContent}
|
||||
|
||||
{cid && (
|
||||
<Section className="flex justify-center my-0">
|
||||
|
@ -43,7 +68,8 @@ export const AlbumInviteEmail = ({
|
|||
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
|
||||
</Text>
|
||||
</ImmichLayout>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
AlbumInviteEmail.PreviewProps = {
|
||||
baseUrl: 'https://demo.immich.app',
|
||||
|
|
|
@ -3,9 +3,27 @@ import * as React from 'react';
|
|||
import { ImmichButton } from 'src/emails/components/button.component';
|
||||
import ImmichLayout from 'src/emails/components/immich.layout';
|
||||
import { AlbumUpdateEmailProps } from 'src/interfaces/notification.interface';
|
||||
import { replaceTemplateTags } from 'src/utils/replace-template-tags';
|
||||
|
||||
export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, cid }: AlbumUpdateEmailProps) => (
|
||||
<ImmichLayout preview="New media has been added to a shared album.">
|
||||
export const AlbumUpdateEmail = ({
|
||||
baseUrl,
|
||||
albumName,
|
||||
recipientName,
|
||||
albumId,
|
||||
cid,
|
||||
customTemplate,
|
||||
}: AlbumUpdateEmailProps) => {
|
||||
const usableTemplateVariables = {
|
||||
albumName,
|
||||
recipientName,
|
||||
albumId,
|
||||
baseUrl,
|
||||
};
|
||||
|
||||
const emailContent = customTemplate ? (
|
||||
replaceTemplateTags(customTemplate, usableTemplateVariables)
|
||||
) : (
|
||||
<>
|
||||
<Text className="m-0">
|
||||
Hey <strong>{recipientName}</strong>!
|
||||
</Text>
|
||||
|
@ -14,6 +32,18 @@ export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, c
|
|||
New media has been added to <strong>{albumName}</strong>,
|
||||
<br /> check it out!
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ImmichLayout preview={customTemplate ? emailContent.toString() : 'New media has been added to a shared album.'}>
|
||||
{customTemplate && (
|
||||
<Text className="m-0">
|
||||
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!customTemplate && emailContent}
|
||||
|
||||
{cid && (
|
||||
<Section className="flex justify-center my-0">
|
||||
|
@ -37,13 +67,16 @@ export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, c
|
|||
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
|
||||
</Text>
|
||||
</ImmichLayout>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
AlbumUpdateEmail.PreviewProps = {
|
||||
baseUrl: 'https://demo.immich.app',
|
||||
albumName: 'Trip to Europe',
|
||||
albumId: 'b63f6dae-e1c9-401b-9a85-9dbbf5612539',
|
||||
recipientName: 'Alan Turing',
|
||||
cid: '',
|
||||
customTemplate: '',
|
||||
} as AlbumUpdateEmailProps;
|
||||
|
||||
export default AlbumUpdateEmail;
|
||||
|
|
|
@ -3,9 +3,20 @@ import * as React from 'react';
|
|||
import { ImmichButton } from 'src/emails/components/button.component';
|
||||
import ImmichLayout from 'src/emails/components/immich.layout';
|
||||
import { WelcomeEmailProps } from 'src/interfaces/notification.interface';
|
||||
import { replaceTemplateTags } from 'src/utils/replace-template-tags';
|
||||
|
||||
export const WelcomeEmail = ({ baseUrl, displayName, username, password }: WelcomeEmailProps) => (
|
||||
<ImmichLayout preview="You have been invited to a new Immich instance.">
|
||||
export const WelcomeEmail = ({ baseUrl, displayName, username, password, customTemplate }: WelcomeEmailProps) => {
|
||||
const usableTemplateVariables = {
|
||||
displayName,
|
||||
username,
|
||||
password,
|
||||
baseUrl,
|
||||
};
|
||||
|
||||
const emailContent = customTemplate ? (
|
||||
replaceTemplateTags(customTemplate, usableTemplateVariables)
|
||||
) : (
|
||||
<>
|
||||
<Text className="m-0">
|
||||
Hey <strong>{displayName}</strong>!
|
||||
</Text>
|
||||
|
@ -21,6 +32,20 @@ export const WelcomeEmail = ({ baseUrl, displayName, username, password }: Welco
|
|||
</>
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ImmichLayout
|
||||
preview={customTemplate ? emailContent.toString() : 'You have been invited to a new Immich instance.'}
|
||||
>
|
||||
{customTemplate && (
|
||||
<Text className="m-0">
|
||||
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!customTemplate && emailContent}
|
||||
|
||||
<Section className="flex justify-center my-6">
|
||||
<ImmichButton href={`${baseUrl}/auth/login`}>Login</ImmichButton>
|
||||
|
@ -32,7 +57,8 @@ export const WelcomeEmail = ({ baseUrl, displayName, username, password }: Welco
|
|||
<Link href={baseUrl}>{baseUrl}</Link>
|
||||
</Text>
|
||||
</ImmichLayout>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
WelcomeEmail.PreviewProps = {
|
||||
baseUrl: 'https://demo.immich.app/auth/login',
|
||||
|
|
|
@ -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, () => {
|
||||
|
|
5
server/src/utils/replace-template-tags.ts
Normal file
5
server/src/utils/replace-template-tags.ts
Normal 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
2
web/package-lock.json
generated
|
@ -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>
|
||||
</form>
|
||||
</div>
|
||||
<TemplateSettings {defaultConfig} {config} {savedConfig} {onReset} {onSave} />
|
||||
|
||||
<SettingButtonsRow
|
||||
onReset={(options) => onReset({ ...options, configKeys: ['notifications'] })}
|
||||
onSave={() => onSave({ notifications: config.notifications })}
|
||||
onReset={(options) => onReset({ ...options, configKeys: ['notifications', 'templates'] })}
|
||||
onSave={() => onSave({ notifications: config.notifications, templates: config.templates })}
|
||||
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
|
||||
{disabled}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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>
|
Loading…
Add table
Reference in a new issue