feat(server): email notifications (#8447)

* feat(server): add `react-mail` as mail template engine and `nodemailer`

* feat(server): add `smtp` related configs to `SystemConfig`

* feat(web): add page for SMTP settings

* feat(server): add `react-email.adapter`

This adapter render the React-Email into HTML and plain/text email.
The output is set as the body of the email.

* feat(server): add `MailRepository` and `MailService`

Allow to use the NestJS-modules-mailer module to send SMTP emails.
This is the base transport for the `NotificationRepository`

* feat(server): register the job dispatcher and Job for async email

This allows to queue email sending jobs for the `EmailService`.

* feat(server): add `NotificationRepository` and `NotificationService`

This act as a middleware to properly route the notification to the right transport.
As POC I've only implemented a simple SMTP transport.

* feat(server): add `welcome` email template

* feat(server): add the first notification on `createUser` in `UserService`

This trigger an event for the `NotificationRepository` that once processes
by using the global config and per-user config will carry the payload to the right notification transport.

* chore: clean up

* chore: clean up web

* fix: type errors"

* fix package lock

* fix mail sending, option to ignore certs

* chore: open api

* chore: clean up

* remove unused import

* feat: email feature flag

* chore: remove unused interface

* small styling

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Nicolò 2024-05-02 16:43:18 +02:00 committed by GitHub
parent 4b86c7a298
commit 9bce3417e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 6499 additions and 371 deletions

View File

@ -75,6 +75,7 @@ describe('/server-info', () => {
search: true,
sidecar: true,
trash: true,
email: false,
});
});
});

View File

@ -170,10 +170,13 @@ doc/SystemConfigLoggingDto.md
doc/SystemConfigMachineLearningDto.md
doc/SystemConfigMapDto.md
doc/SystemConfigNewVersionCheckDto.md
doc/SystemConfigNotificationsDto.md
doc/SystemConfigOAuthDto.md
doc/SystemConfigPasswordLoginDto.md
doc/SystemConfigReverseGeocodingDto.md
doc/SystemConfigServerDto.md
doc/SystemConfigSmtpDto.md
doc/SystemConfigSmtpTransportDto.md
doc/SystemConfigStorageTemplateDto.md
doc/SystemConfigTemplateStorageOptionDto.md
doc/SystemConfigThemeDto.md
@ -392,10 +395,13 @@ lib/model/system_config_logging_dto.dart
lib/model/system_config_machine_learning_dto.dart
lib/model/system_config_map_dto.dart
lib/model/system_config_new_version_check_dto.dart
lib/model/system_config_notifications_dto.dart
lib/model/system_config_o_auth_dto.dart
lib/model/system_config_password_login_dto.dart
lib/model/system_config_reverse_geocoding_dto.dart
lib/model/system_config_server_dto.dart
lib/model/system_config_smtp_dto.dart
lib/model/system_config_smtp_transport_dto.dart
lib/model/system_config_storage_template_dto.dart
lib/model/system_config_template_storage_option_dto.dart
lib/model/system_config_theme_dto.dart
@ -595,10 +601,13 @@ test/system_config_logging_dto_test.dart
test/system_config_machine_learning_dto_test.dart
test/system_config_map_dto_test.dart
test/system_config_new_version_check_dto_test.dart
test/system_config_notifications_dto_test.dart
test/system_config_o_auth_dto_test.dart
test/system_config_password_login_dto_test.dart
test/system_config_reverse_geocoding_dto_test.dart
test/system_config_server_dto_test.dart
test/system_config_smtp_dto_test.dart
test/system_config_smtp_transport_dto_test.dart
test/system_config_storage_template_dto_test.dart
test/system_config_template_storage_option_dto_test.dart
test/system_config_theme_dto_test.dart

View File

@ -369,10 +369,13 @@ Class | Method | HTTP request | Description
- [SystemConfigMachineLearningDto](doc//SystemConfigMachineLearningDto.md)
- [SystemConfigMapDto](doc//SystemConfigMapDto.md)
- [SystemConfigNewVersionCheckDto](doc//SystemConfigNewVersionCheckDto.md)
- [SystemConfigNotificationsDto](doc//SystemConfigNotificationsDto.md)
- [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md)
- [SystemConfigPasswordLoginDto](doc//SystemConfigPasswordLoginDto.md)
- [SystemConfigReverseGeocodingDto](doc//SystemConfigReverseGeocodingDto.md)
- [SystemConfigServerDto](doc//SystemConfigServerDto.md)
- [SystemConfigSmtpDto](doc//SystemConfigSmtpDto.md)
- [SystemConfigSmtpTransportDto](doc//SystemConfigSmtpTransportDto.md)
- [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md)
- [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md)
- [SystemConfigThemeDto](doc//SystemConfigThemeDto.md)

View File

@ -14,6 +14,7 @@ Name | Type | Description | Notes
**library_** | [**JobStatusDto**](JobStatusDto.md) | |
**metadataExtraction** | [**JobStatusDto**](JobStatusDto.md) | |
**migration** | [**JobStatusDto**](JobStatusDto.md) | |
**notifications** | [**JobStatusDto**](JobStatusDto.md) | |
**search** | [**JobStatusDto**](JobStatusDto.md) | |
**sidecar** | [**JobStatusDto**](JobStatusDto.md) | |
**smartSearch** | [**JobStatusDto**](JobStatusDto.md) | |

View File

@ -11,6 +11,7 @@ Name | Type | Description | Notes
**email** | **String** | |
**memoriesEnabled** | **bool** | | [optional]
**name** | **String** | |
**notify** | **bool** | | [optional]
**password** | **String** | |
**quotaSizeInBytes** | **int** | | [optional]
**shouldChangePassword** | **bool** | | [optional]

View File

@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**configFile** | **bool** | |
**email** | **bool** | |
**facialRecognition** | **bool** | |
**map** | **bool** | |
**oauth** | **bool** | |

View File

@ -16,6 +16,7 @@ Name | Type | Description | Notes
**machineLearning** | [**SystemConfigMachineLearningDto**](SystemConfigMachineLearningDto.md) | |
**map** | [**SystemConfigMapDto**](SystemConfigMapDto.md) | |
**newVersionCheck** | [**SystemConfigNewVersionCheckDto**](SystemConfigNewVersionCheckDto.md) | |
**notifications** | [**SystemConfigNotificationsDto**](SystemConfigNotificationsDto.md) | |
**oauth** | [**SystemConfigOAuthDto**](SystemConfigOAuthDto.md) | |
**passwordLogin** | [**SystemConfigPasswordLoginDto**](SystemConfigPasswordLoginDto.md) | |
**reverseGeocoding** | [**SystemConfigReverseGeocodingDto**](SystemConfigReverseGeocodingDto.md) | |

View File

@ -13,6 +13,7 @@ Name | Type | Description | Notes
**library_** | [**JobSettingsDto**](JobSettingsDto.md) | |
**metadataExtraction** | [**JobSettingsDto**](JobSettingsDto.md) | |
**migration** | [**JobSettingsDto**](JobSettingsDto.md) | |
**notifications** | [**JobSettingsDto**](JobSettingsDto.md) | |
**search** | [**JobSettingsDto**](JobSettingsDto.md) | |
**sidecar** | [**JobSettingsDto**](JobSettingsDto.md) | |
**smartSearch** | [**JobSettingsDto**](JobSettingsDto.md) | |

View File

@ -0,0 +1,15 @@
# openapi.model.SystemConfigNotificationsDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**smtp** | [**SystemConfigSmtpDto**](SystemConfigSmtpDto.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -0,0 +1,18 @@
# openapi.model.SystemConfigSmtpDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**enabled** | **bool** | |
**from** | **String** | |
**replyTo** | **String** | |
**transport** | [**SystemConfigSmtpTransportDto**](SystemConfigSmtpTransportDto.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -0,0 +1,19 @@
# openapi.model.SystemConfigSmtpTransportDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**host** | **String** | |
**ignoreCert** | **bool** | |
**password** | **String** | |
**port** | **num** | |
**username** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -202,10 +202,13 @@ part 'model/system_config_logging_dto.dart';
part 'model/system_config_machine_learning_dto.dart';
part 'model/system_config_map_dto.dart';
part 'model/system_config_new_version_check_dto.dart';
part 'model/system_config_notifications_dto.dart';
part 'model/system_config_o_auth_dto.dart';
part 'model/system_config_password_login_dto.dart';
part 'model/system_config_reverse_geocoding_dto.dart';
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_storage_option_dto.dart';
part 'model/system_config_theme_dto.dart';

View File

@ -474,6 +474,8 @@ class ApiClient {
return SystemConfigMapDto.fromJson(value);
case 'SystemConfigNewVersionCheckDto':
return SystemConfigNewVersionCheckDto.fromJson(value);
case 'SystemConfigNotificationsDto':
return SystemConfigNotificationsDto.fromJson(value);
case 'SystemConfigOAuthDto':
return SystemConfigOAuthDto.fromJson(value);
case 'SystemConfigPasswordLoginDto':
@ -482,6 +484,10 @@ class ApiClient {
return SystemConfigReverseGeocodingDto.fromJson(value);
case 'SystemConfigServerDto':
return SystemConfigServerDto.fromJson(value);
case 'SystemConfigSmtpDto':
return SystemConfigSmtpDto.fromJson(value);
case 'SystemConfigSmtpTransportDto':
return SystemConfigSmtpTransportDto.fromJson(value);
case 'SystemConfigStorageTemplateDto':
return SystemConfigStorageTemplateDto.fromJson(value);
case 'SystemConfigTemplateStorageOptionDto':

View File

@ -19,6 +19,7 @@ class AllJobStatusResponseDto {
required this.library_,
required this.metadataExtraction,
required this.migration,
required this.notifications,
required this.search,
required this.sidecar,
required this.smartSearch,
@ -39,6 +40,8 @@ class AllJobStatusResponseDto {
JobStatusDto migration;
JobStatusDto notifications;
JobStatusDto search;
JobStatusDto sidecar;
@ -59,6 +62,7 @@ class AllJobStatusResponseDto {
other.library_ == library_ &&
other.metadataExtraction == metadataExtraction &&
other.migration == migration &&
other.notifications == notifications &&
other.search == search &&
other.sidecar == sidecar &&
other.smartSearch == smartSearch &&
@ -75,6 +79,7 @@ class AllJobStatusResponseDto {
(library_.hashCode) +
(metadataExtraction.hashCode) +
(migration.hashCode) +
(notifications.hashCode) +
(search.hashCode) +
(sidecar.hashCode) +
(smartSearch.hashCode) +
@ -83,7 +88,7 @@ class AllJobStatusResponseDto {
(videoConversion.hashCode);
@override
String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -93,6 +98,7 @@ class AllJobStatusResponseDto {
json[r'library'] = this.library_;
json[r'metadataExtraction'] = this.metadataExtraction;
json[r'migration'] = this.migration;
json[r'notifications'] = this.notifications;
json[r'search'] = this.search;
json[r'sidecar'] = this.sidecar;
json[r'smartSearch'] = this.smartSearch;
@ -116,6 +122,7 @@ class AllJobStatusResponseDto {
library_: JobStatusDto.fromJson(json[r'library'])!,
metadataExtraction: JobStatusDto.fromJson(json[r'metadataExtraction'])!,
migration: JobStatusDto.fromJson(json[r'migration'])!,
notifications: JobStatusDto.fromJson(json[r'notifications'])!,
search: JobStatusDto.fromJson(json[r'search'])!,
sidecar: JobStatusDto.fromJson(json[r'sidecar'])!,
smartSearch: JobStatusDto.fromJson(json[r'smartSearch'])!,
@ -175,6 +182,7 @@ class AllJobStatusResponseDto {
'library',
'metadataExtraction',
'migration',
'notifications',
'search',
'sidecar',
'smartSearch',

View File

@ -16,6 +16,7 @@ class CreateUserDto {
required this.email,
this.memoriesEnabled,
required this.name,
this.notify,
required this.password,
this.quotaSizeInBytes,
this.shouldChangePassword,
@ -34,6 +35,14 @@ class CreateUserDto {
String name;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? notify;
String password;
/// Minimum value: 1
@ -54,6 +63,7 @@ class CreateUserDto {
other.email == email &&
other.memoriesEnabled == memoriesEnabled &&
other.name == name &&
other.notify == notify &&
other.password == password &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.shouldChangePassword == shouldChangePassword &&
@ -65,13 +75,14 @@ class CreateUserDto {
(email.hashCode) +
(memoriesEnabled == null ? 0 : memoriesEnabled!.hashCode) +
(name.hashCode) +
(notify == null ? 0 : notify!.hashCode) +
(password.hashCode) +
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode);
@override
String toString() => 'CreateUserDto[email=$email, memoriesEnabled=$memoriesEnabled, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
String toString() => 'CreateUserDto[email=$email, memoriesEnabled=$memoriesEnabled, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -82,6 +93,11 @@ class CreateUserDto {
// json[r'memoriesEnabled'] = null;
}
json[r'name'] = this.name;
if (this.notify != null) {
json[r'notify'] = this.notify;
} else {
// json[r'notify'] = null;
}
json[r'password'] = this.password;
if (this.quotaSizeInBytes != null) {
json[r'quotaSizeInBytes'] = this.quotaSizeInBytes;
@ -112,6 +128,7 @@ class CreateUserDto {
email: mapValueOfType<String>(json, r'email')!,
memoriesEnabled: mapValueOfType<bool>(json, r'memoriesEnabled'),
name: mapValueOfType<String>(json, r'name')!,
notify: mapValueOfType<bool>(json, r'notify'),
password: mapValueOfType<String>(json, r'password')!,
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'),

View File

@ -35,6 +35,7 @@ class JobName {
static const search = JobName._(r'search');
static const sidecar = JobName._(r'sidecar');
static const library_ = JobName._(r'library');
static const notifications = JobName._(r'notifications');
/// List of all possible values in this [enum][JobName].
static const values = <JobName>[
@ -50,6 +51,7 @@ class JobName {
search,
sidecar,
library_,
notifications,
];
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
@ -100,6 +102,7 @@ class JobNameTypeTransformer {
case r'search': return JobName.search;
case r'sidecar': return JobName.sidecar;
case r'library': return JobName.library_;
case r'notifications': return JobName.notifications;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

View File

@ -14,6 +14,7 @@ class ServerFeaturesDto {
/// Returns a new [ServerFeaturesDto] instance.
ServerFeaturesDto({
required this.configFile,
required this.email,
required this.facialRecognition,
required this.map,
required this.oauth,
@ -28,6 +29,8 @@ class ServerFeaturesDto {
bool configFile;
bool email;
bool facialRecognition;
bool map;
@ -51,6 +54,7 @@ class ServerFeaturesDto {
@override
bool operator ==(Object other) => identical(this, other) || other is ServerFeaturesDto &&
other.configFile == configFile &&
other.email == email &&
other.facialRecognition == facialRecognition &&
other.map == map &&
other.oauth == oauth &&
@ -66,6 +70,7 @@ class ServerFeaturesDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(configFile.hashCode) +
(email.hashCode) +
(facialRecognition.hashCode) +
(map.hashCode) +
(oauth.hashCode) +
@ -78,11 +83,12 @@ class ServerFeaturesDto {
(trash.hashCode);
@override
String toString() => 'ServerFeaturesDto[configFile=$configFile, facialRecognition=$facialRecognition, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
String toString() => 'ServerFeaturesDto[configFile=$configFile, email=$email, facialRecognition=$facialRecognition, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'configFile'] = this.configFile;
json[r'email'] = this.email;
json[r'facialRecognition'] = this.facialRecognition;
json[r'map'] = this.map;
json[r'oauth'] = this.oauth;
@ -105,6 +111,7 @@ class ServerFeaturesDto {
return ServerFeaturesDto(
configFile: mapValueOfType<bool>(json, r'configFile')!,
email: mapValueOfType<bool>(json, r'email')!,
facialRecognition: mapValueOfType<bool>(json, r'facialRecognition')!,
map: mapValueOfType<bool>(json, r'map')!,
oauth: mapValueOfType<bool>(json, r'oauth')!,
@ -163,6 +170,7 @@ class ServerFeaturesDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'configFile',
'email',
'facialRecognition',
'map',
'oauth',

View File

@ -21,6 +21,7 @@ class SystemConfigDto {
required this.machineLearning,
required this.map,
required this.newVersionCheck,
required this.notifications,
required this.oauth,
required this.passwordLogin,
required this.reverseGeocoding,
@ -47,6 +48,8 @@ class SystemConfigDto {
SystemConfigNewVersionCheckDto newVersionCheck;
SystemConfigNotificationsDto notifications;
SystemConfigOAuthDto oauth;
SystemConfigPasswordLoginDto passwordLogin;
@ -73,6 +76,7 @@ class SystemConfigDto {
other.machineLearning == machineLearning &&
other.map == map &&
other.newVersionCheck == newVersionCheck &&
other.notifications == notifications &&
other.oauth == oauth &&
other.passwordLogin == passwordLogin &&
other.reverseGeocoding == reverseGeocoding &&
@ -93,6 +97,7 @@ class SystemConfigDto {
(machineLearning.hashCode) +
(map.hashCode) +
(newVersionCheck.hashCode) +
(notifications.hashCode) +
(oauth.hashCode) +
(passwordLogin.hashCode) +
(reverseGeocoding.hashCode) +
@ -103,7 +108,7 @@ class SystemConfigDto {
(user.hashCode);
@override
String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, image=$image, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, trash=$trash, user=$user]';
String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, image=$image, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, notifications=$notifications, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, trash=$trash, user=$user]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -115,6 +120,7 @@ class SystemConfigDto {
json[r'machineLearning'] = this.machineLearning;
json[r'map'] = this.map;
json[r'newVersionCheck'] = this.newVersionCheck;
json[r'notifications'] = this.notifications;
json[r'oauth'] = this.oauth;
json[r'passwordLogin'] = this.passwordLogin;
json[r'reverseGeocoding'] = this.reverseGeocoding;
@ -142,6 +148,7 @@ class SystemConfigDto {
machineLearning: SystemConfigMachineLearningDto.fromJson(json[r'machineLearning'])!,
map: SystemConfigMapDto.fromJson(json[r'map'])!,
newVersionCheck: SystemConfigNewVersionCheckDto.fromJson(json[r'newVersionCheck'])!,
notifications: SystemConfigNotificationsDto.fromJson(json[r'notifications'])!,
oauth: SystemConfigOAuthDto.fromJson(json[r'oauth'])!,
passwordLogin: SystemConfigPasswordLoginDto.fromJson(json[r'passwordLogin'])!,
reverseGeocoding: SystemConfigReverseGeocodingDto.fromJson(json[r'reverseGeocoding'])!,
@ -205,6 +212,7 @@ class SystemConfigDto {
'machineLearning',
'map',
'newVersionCheck',
'notifications',
'oauth',
'passwordLogin',
'reverseGeocoding',

View File

@ -18,6 +18,7 @@ class SystemConfigJobDto {
required this.library_,
required this.metadataExtraction,
required this.migration,
required this.notifications,
required this.search,
required this.sidecar,
required this.smartSearch,
@ -35,6 +36,8 @@ class SystemConfigJobDto {
JobSettingsDto migration;
JobSettingsDto notifications;
JobSettingsDto search;
JobSettingsDto sidecar;
@ -52,6 +55,7 @@ class SystemConfigJobDto {
other.library_ == library_ &&
other.metadataExtraction == metadataExtraction &&
other.migration == migration &&
other.notifications == notifications &&
other.search == search &&
other.sidecar == sidecar &&
other.smartSearch == smartSearch &&
@ -66,6 +70,7 @@ class SystemConfigJobDto {
(library_.hashCode) +
(metadataExtraction.hashCode) +
(migration.hashCode) +
(notifications.hashCode) +
(search.hashCode) +
(sidecar.hashCode) +
(smartSearch.hashCode) +
@ -73,7 +78,7 @@ class SystemConfigJobDto {
(videoConversion.hashCode);
@override
String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -82,6 +87,7 @@ class SystemConfigJobDto {
json[r'library'] = this.library_;
json[r'metadataExtraction'] = this.metadataExtraction;
json[r'migration'] = this.migration;
json[r'notifications'] = this.notifications;
json[r'search'] = this.search;
json[r'sidecar'] = this.sidecar;
json[r'smartSearch'] = this.smartSearch;
@ -103,6 +109,7 @@ class SystemConfigJobDto {
library_: JobSettingsDto.fromJson(json[r'library'])!,
metadataExtraction: JobSettingsDto.fromJson(json[r'metadataExtraction'])!,
migration: JobSettingsDto.fromJson(json[r'migration'])!,
notifications: JobSettingsDto.fromJson(json[r'notifications'])!,
search: JobSettingsDto.fromJson(json[r'search'])!,
sidecar: JobSettingsDto.fromJson(json[r'sidecar'])!,
smartSearch: JobSettingsDto.fromJson(json[r'smartSearch'])!,
@ -160,6 +167,7 @@ class SystemConfigJobDto {
'library',
'metadataExtraction',
'migration',
'notifications',
'search',
'sidecar',
'smartSearch',

View File

@ -0,0 +1,98 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// 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 SystemConfigNotificationsDto {
/// Returns a new [SystemConfigNotificationsDto] instance.
SystemConfigNotificationsDto({
required this.smtp,
});
SystemConfigSmtpDto smtp;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigNotificationsDto &&
other.smtp == smtp;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(smtp.hashCode);
@override
String toString() => 'SystemConfigNotificationsDto[smtp=$smtp]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'smtp'] = this.smtp;
return json;
}
/// Returns a new [SystemConfigNotificationsDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SystemConfigNotificationsDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return SystemConfigNotificationsDto(
smtp: SystemConfigSmtpDto.fromJson(json[r'smtp'])!,
);
}
return null;
}
static List<SystemConfigNotificationsDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SystemConfigNotificationsDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SystemConfigNotificationsDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SystemConfigNotificationsDto> mapFromJson(dynamic json) {
final map = <String, SystemConfigNotificationsDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SystemConfigNotificationsDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SystemConfigNotificationsDto-objects as value to a dart map
static Map<String, List<SystemConfigNotificationsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SystemConfigNotificationsDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SystemConfigNotificationsDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'smtp',
};
}

View File

@ -0,0 +1,122 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// 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 SystemConfigSmtpDto {
/// Returns a new [SystemConfigSmtpDto] instance.
SystemConfigSmtpDto({
required this.enabled,
required this.from,
required this.replyTo,
required this.transport,
});
bool enabled;
String from;
String replyTo;
SystemConfigSmtpTransportDto transport;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigSmtpDto &&
other.enabled == enabled &&
other.from == from &&
other.replyTo == replyTo &&
other.transport == transport;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(enabled.hashCode) +
(from.hashCode) +
(replyTo.hashCode) +
(transport.hashCode);
@override
String toString() => 'SystemConfigSmtpDto[enabled=$enabled, from=$from, replyTo=$replyTo, transport=$transport]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'enabled'] = this.enabled;
json[r'from'] = this.from;
json[r'replyTo'] = this.replyTo;
json[r'transport'] = this.transport;
return json;
}
/// Returns a new [SystemConfigSmtpDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SystemConfigSmtpDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return SystemConfigSmtpDto(
enabled: mapValueOfType<bool>(json, r'enabled')!,
from: mapValueOfType<String>(json, r'from')!,
replyTo: mapValueOfType<String>(json, r'replyTo')!,
transport: SystemConfigSmtpTransportDto.fromJson(json[r'transport'])!,
);
}
return null;
}
static List<SystemConfigSmtpDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SystemConfigSmtpDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SystemConfigSmtpDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SystemConfigSmtpDto> mapFromJson(dynamic json) {
final map = <String, SystemConfigSmtpDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SystemConfigSmtpDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SystemConfigSmtpDto-objects as value to a dart map
static Map<String, List<SystemConfigSmtpDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SystemConfigSmtpDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SystemConfigSmtpDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'enabled',
'from',
'replyTo',
'transport',
};
}

View File

@ -0,0 +1,132 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// 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 SystemConfigSmtpTransportDto {
/// Returns a new [SystemConfigSmtpTransportDto] instance.
SystemConfigSmtpTransportDto({
required this.host,
required this.ignoreCert,
required this.password,
required this.port,
required this.username,
});
String host;
bool ignoreCert;
String password;
/// Minimum value: 0
/// Maximum value: 65535
num port;
String username;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigSmtpTransportDto &&
other.host == host &&
other.ignoreCert == ignoreCert &&
other.password == password &&
other.port == port &&
other.username == username;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(host.hashCode) +
(ignoreCert.hashCode) +
(password.hashCode) +
(port.hashCode) +
(username.hashCode);
@override
String toString() => 'SystemConfigSmtpTransportDto[host=$host, ignoreCert=$ignoreCert, password=$password, port=$port, username=$username]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'host'] = this.host;
json[r'ignoreCert'] = this.ignoreCert;
json[r'password'] = this.password;
json[r'port'] = this.port;
json[r'username'] = this.username;
return json;
}
/// Returns a new [SystemConfigSmtpTransportDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SystemConfigSmtpTransportDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return SystemConfigSmtpTransportDto(
host: mapValueOfType<String>(json, r'host')!,
ignoreCert: mapValueOfType<bool>(json, r'ignoreCert')!,
password: mapValueOfType<String>(json, r'password')!,
port: num.parse('${json[r'port']}'),
username: mapValueOfType<String>(json, r'username')!,
);
}
return null;
}
static List<SystemConfigSmtpTransportDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SystemConfigSmtpTransportDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SystemConfigSmtpTransportDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SystemConfigSmtpTransportDto> mapFromJson(dynamic json) {
final map = <String, SystemConfigSmtpTransportDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SystemConfigSmtpTransportDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SystemConfigSmtpTransportDto-objects as value to a dart map
static Map<String, List<SystemConfigSmtpTransportDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SystemConfigSmtpTransportDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SystemConfigSmtpTransportDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'host',
'ignoreCert',
'password',
'port',
'username',
};
}

View File

@ -46,6 +46,11 @@ void main() {
// TODO
});
// JobStatusDto notifications
test('to test the property `notifications`', () async {
// TODO
});
// JobStatusDto search
test('to test the property `search`', () async {
// TODO

View File

@ -31,6 +31,11 @@ void main() {
// TODO
});
// bool notify
test('to test the property `notify`', () async {
// TODO
});
// String password
test('to test the property `password`', () async {
// TODO

View File

@ -21,6 +21,11 @@ void main() {
// TODO
});
// bool email
test('to test the property `email`', () async {
// TODO
});
// bool facialRecognition
test('to test the property `facialRecognition`', () async {
// TODO

View File

@ -56,6 +56,11 @@ void main() {
// TODO
});
// SystemConfigNotificationsDto notifications
test('to test the property `notifications`', () async {
// TODO
});
// SystemConfigOAuthDto oauth
test('to test the property `oauth`', () async {
// TODO

View File

@ -41,6 +41,11 @@ void main() {
// TODO
});
// JobSettingsDto notifications
test('to test the property `notifications`', () async {
// TODO
});
// JobSettingsDto search
test('to test the property `search`', () async {
// TODO

View File

@ -0,0 +1,27 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// 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
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for SystemConfigNotificationsDto
void main() {
// final instance = SystemConfigNotificationsDto();
group('test SystemConfigNotificationsDto', () {
// SystemConfigSmtpDto smtp
test('to test the property `smtp`', () async {
// TODO
});
});
}

View File

@ -0,0 +1,42 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// 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
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for SystemConfigSmtpDto
void main() {
// final instance = SystemConfigSmtpDto();
group('test SystemConfigSmtpDto', () {
// bool enabled
test('to test the property `enabled`', () async {
// TODO
});
// String from
test('to test the property `from`', () async {
// TODO
});
// String replyTo
test('to test the property `replyTo`', () async {
// TODO
});
// SystemConfigSmtpTransportDto transport
test('to test the property `transport`', () async {
// TODO
});
});
}

View File

@ -0,0 +1,47 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// 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
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for SystemConfigSmtpTransportDto
void main() {
// final instance = SystemConfigSmtpTransportDto();
group('test SystemConfigSmtpTransportDto', () {
// String host
test('to test the property `host`', () async {
// TODO
});
// bool ignoreCert
test('to test the property `ignoreCert`', () async {
// TODO
});
// String password
test('to test the property `password`', () async {
// TODO
});
// num port
test('to test the property `port`', () async {
// TODO
});
// String username
test('to test the property `username`', () async {
// TODO
});
});
}

View File

@ -6812,6 +6812,9 @@
"migration": {
"$ref": "#/components/schemas/JobStatusDto"
},
"notifications": {
"$ref": "#/components/schemas/JobStatusDto"
},
"search": {
"$ref": "#/components/schemas/JobStatusDto"
},
@ -6838,6 +6841,7 @@
"library",
"metadataExtraction",
"migration",
"notifications",
"search",
"sidecar",
"smartSearch",
@ -7754,6 +7758,9 @@
"name": {
"type": "string"
},
"notify": {
"type": "boolean"
},
"password": {
"type": "string"
},
@ -8145,7 +8152,8 @@
"migration",
"search",
"sidecar",
"library"
"library",
"notifications"
],
"type": "string"
},
@ -9357,6 +9365,9 @@
"configFile": {
"type": "boolean"
},
"email": {
"type": "boolean"
},
"facialRecognition": {
"type": "boolean"
},
@ -9390,6 +9401,7 @@
},
"required": [
"configFile",
"email",
"facialRecognition",
"map",
"oauth",
@ -9925,6 +9937,9 @@
"newVersionCheck": {
"$ref": "#/components/schemas/SystemConfigNewVersionCheckDto"
},
"notifications": {
"$ref": "#/components/schemas/SystemConfigNotificationsDto"
},
"oauth": {
"$ref": "#/components/schemas/SystemConfigOAuthDto"
},
@ -9959,6 +9974,7 @@
"machineLearning",
"map",
"newVersionCheck",
"notifications",
"oauth",
"passwordLogin",
"reverseGeocoding",
@ -10128,6 +10144,9 @@
"migration": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"notifications": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"search": {
"$ref": "#/components/schemas/JobSettingsDto"
},
@ -10150,6 +10169,7 @@
"library",
"metadataExtraction",
"migration",
"notifications",
"search",
"sidecar",
"smartSearch",
@ -10267,6 +10287,17 @@
],
"type": "object"
},
"SystemConfigNotificationsDto": {
"properties": {
"smtp": {
"$ref": "#/components/schemas/SystemConfigSmtpDto"
}
},
"required": [
"smtp"
],
"type": "object"
},
"SystemConfigOAuthDto": {
"properties": {
"autoLaunch": {
@ -10368,6 +10399,58 @@
],
"type": "object"
},
"SystemConfigSmtpDto": {
"properties": {
"enabled": {
"type": "boolean"
},
"from": {
"type": "string"
},
"replyTo": {
"type": "string"
},
"transport": {
"$ref": "#/components/schemas/SystemConfigSmtpTransportDto"
}
},
"required": [
"enabled",
"from",
"replyTo",
"transport"
],
"type": "object"
},
"SystemConfigSmtpTransportDto": {
"properties": {
"host": {
"type": "string"
},
"ignoreCert": {
"type": "boolean"
},
"password": {
"type": "string"
},
"port": {
"maximum": 65535,
"minimum": 0,
"type": "number"
},
"username": {
"type": "string"
}
},
"required": [
"host",
"ignoreCert",
"password",
"port",
"username"
],
"type": "object"
},
"SystemConfigStorageTemplateDto": {
"properties": {
"enabled": {

View File

@ -407,6 +407,7 @@ export type AllJobStatusResponseDto = {
library: JobStatusDto;
metadataExtraction: JobStatusDto;
migration: JobStatusDto;
notifications: JobStatusDto;
search: JobStatusDto;
sidecar: JobStatusDto;
smartSearch: JobStatusDto;
@ -745,6 +746,7 @@ export type ServerConfigDto = {
};
export type ServerFeaturesDto = {
configFile: boolean;
email: boolean;
facialRecognition: boolean;
map: boolean;
oauth: boolean;
@ -895,6 +897,7 @@ export type SystemConfigJobDto = {
library: JobSettingsDto;
metadataExtraction: JobSettingsDto;
migration: JobSettingsDto;
notifications: JobSettingsDto;
search: JobSettingsDto;
sidecar: JobSettingsDto;
smartSearch: JobSettingsDto;
@ -944,6 +947,22 @@ export type SystemConfigMapDto = {
export type SystemConfigNewVersionCheckDto = {
enabled: boolean;
};
export type SystemConfigSmtpTransportDto = {
host: string;
ignoreCert: boolean;
password: string;
port: number;
username: string;
};
export type SystemConfigSmtpDto = {
enabled: boolean;
"from": string;
replyTo: string;
transport: SystemConfigSmtpTransportDto;
};
export type SystemConfigNotificationsDto = {
smtp: SystemConfigSmtpDto;
};
export type SystemConfigOAuthDto = {
autoLaunch: boolean;
autoRegister: boolean;
@ -994,6 +1013,7 @@ export type SystemConfigDto = {
machineLearning: SystemConfigMachineLearningDto;
map: SystemConfigMapDto;
newVersionCheck: SystemConfigNewVersionCheckDto;
notifications: SystemConfigNotificationsDto;
oauth: SystemConfigOAuthDto;
passwordLogin: SystemConfigPasswordLoginDto;
reverseGeocoding: SystemConfigReverseGeocodingDto;
@ -1035,6 +1055,7 @@ export type CreateUserDto = {
email: string;
memoriesEnabled?: boolean;
name: string;
notify?: boolean;
password: string;
quotaSizeInBytes?: number | null;
shouldChangePassword?: boolean;
@ -2852,7 +2873,8 @@ export enum JobName {
Migration = "migration",
Search = "search",
Sidecar = "sidecar",
Library = "library"
Library = "library",
Notifications = "notifications"
}
export enum JobCommand {
Start = "start",

5431
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -29,7 +29,8 @@
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/database.config.js",
"typeorm:schema:drop": "typeorm query -d ./dist/database.config.js 'DROP schema public cascade; CREATE schema public;'",
"typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run",
"sql:generate": "node ./dist/utils/sql.js"
"sql:generate": "node ./dist/utils/sql.js",
"email:dev": "email dev -p 3050 --dir src/emails"
},
"dependencies": {
"@nestjs/bullmq": "^10.0.1",
@ -47,6 +48,7 @@
"@opentelemetry/context-async-hooks": "^1.24.0",
"@opentelemetry/exporter-prometheus": "^0.51.0",
"@opentelemetry/sdk-node": "^0.51.0",
"@react-email/components": "^0.0.17",
"@socket.io/postgres-adapter": "^0.3.1",
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
@ -72,9 +74,11 @@
"nest-commander": "^3.11.1",
"nestjs-cls": "^4.3.0",
"nestjs-otel": "^5.1.5",
"nodemailer": "^6.9.13",
"openid-client": "^5.4.3",
"pg": "^8.11.3",
"picomatch": "^4.0.0",
"react-email": "^2.1.2",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
@ -102,7 +106,9 @@
"@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7",
"@types/node": "^20.5.7",
"@types/nodemailer": "^6.4.14",
"@types/picomatch": "^2.3.3",
"@types/supertest": "^6.0.0",
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",

View File

@ -61,6 +61,7 @@ export const defaults = Object.freeze<SystemConfig>({
[QueueName.MIGRATION]: { concurrency: 5 },
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
[QueueName.NOTIFICATION]: { concurrency: 5 },
},
logging: {
enabled: true,
@ -145,6 +146,20 @@ export const defaults = Object.freeze<SystemConfig>({
externalDomain: '',
loginPageMessage: '',
},
notifications: {
smtp: {
enabled: false,
from: '',
replyTo: '',
transport: {
ignoreCert: false,
host: '',
port: 587,
username: '',
password: '',
},
},
},
user: {
deleteDelay: 7,
},
@ -162,6 +177,7 @@ export enum FeatureFlag {
PASSWORD_LOGIN = 'passwordLogin',
CONFIG_FILE = 'configFile',
TRASH = 'trash',
EMAIL = 'email',
}
export type FeatureFlags = Record<FeatureFlag, boolean>;
@ -243,6 +259,7 @@ export class SystemConfigCore {
[FeatureFlag.OAUTH_AUTO_LAUNCH]: config.oauth.autoLaunch,
[FeatureFlag.PASSWORD_LOGIN]: config.passwordLogin.enabled,
[FeatureFlag.CONFIG_FILE]: !!process.env.IMMICH_CONFIG_FILE,
[FeatureFlag.EMAIL]: config.notifications.smtp.enabled,
};
}

View File

@ -84,4 +84,7 @@ export class AllJobStatusResponseDto implements Record<QueueName, JobStatusDto>
@ApiProperty({ type: JobStatusDto })
[QueueName.LIBRARY]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.NOTIFICATION]!: JobStatusDto;
}

View File

@ -108,6 +108,7 @@ export class ServerFeaturesDto implements FeatureFlags {
passwordLogin!: boolean;
sidecar!: boolean;
search!: boolean;
email!: boolean;
}
export interface ReleaseNotification {

View File

@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsInt,
IsNotEmpty,
@ -43,6 +44,7 @@ class CronValidator implements ValidatorConstraintInterface {
const isLibraryScanEnabled = (config: SystemConfigLibraryScanDto) => config.enabled;
const isOAuthEnabled = (config: SystemConfigOAuthDto) => config.enabled;
const isOAuthOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled;
const isEmailNotificationEnabled = (config: SystemConfigSmtpDto) => config.enabled;
export class SystemConfigFFmpegDto {
@IsInt()
@ -202,6 +204,12 @@ class SystemConfigJobDto implements Record<ConcurrentQueueName, JobSettingsDto>
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.LIBRARY]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.NOTIFICATION]!: JobSettingsDto;
}
class SystemConfigLibraryScanDto {
@ -358,6 +366,53 @@ class SystemConfigServerDto {
loginPageMessage!: string;
}
class SystemConfigSmtpTransportDto {
@IsBoolean()
ignoreCert!: boolean;
@IsNotEmpty()
@IsString()
host!: string;
@IsNumber()
@Min(0)
@Max(65_535)
port!: number;
@IsString()
username!: string;
@IsString()
password!: string;
}
class SystemConfigSmtpDto {
@IsBoolean()
enabled!: boolean;
@ValidateIf(isEmailNotificationEnabled)
@IsNotEmpty()
@IsString()
@IsNotEmpty()
from!: string;
@IsString()
replyTo!: string;
@ValidateIf(isEmailNotificationEnabled)
@Type(() => SystemConfigSmtpTransportDto)
@ValidateNested()
@IsObject()
transport!: SystemConfigSmtpTransportDto;
}
class SystemConfigNotificationsDto {
@Type(() => SystemConfigSmtpDto)
@ValidateNested()
@IsObject()
smtp!: SystemConfigSmtpDto;
}
class SystemConfigStorageTemplateDto {
@ValidateBoolean()
enabled!: boolean;
@ -512,6 +567,11 @@ export class SystemConfigDto implements SystemConfig {
@IsObject()
library!: SystemConfigLibraryDto;
@Type(() => SystemConfigNotificationsDto)
@ValidateNested()
@IsObject()
notifications!: SystemConfigNotificationsDto;
@Type(() => SystemConfigServerDto)
@ValidateNested()
@IsObject()

View File

@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator';
import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator';
import { getRandomAvatarColor } from 'src/dtos/user-profile.dto';
import { UserAvatarColor, UserEntity, UserStatus } from 'src/entities/user.entity';
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
@ -34,6 +34,10 @@ export class CreateUserDto {
@ValidateBoolean({ optional: true })
shouldChangePassword?: boolean;
@Optional()
@IsBoolean()
notify?: boolean;
}
export class CreateAdminDto {

View File

@ -0,0 +1,159 @@
import {
Body,
Button,
Column,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components';
import * as CSS from 'csstype';
import * as React from 'react';
import { WelcomeEmailProps } from 'src/interfaces/notification.interface';
export const WelcomeEmail = ({ baseUrl, displayName, username, password }: WelcomeEmailProps) => (
<Html>
<Head />
<Preview>You have been invited to a new Immich instance.</Preview>
<Body
style={{
margin: 0,
padding: 0,
backgroundColor: '#ffffff',
color: 'rgb(66, 80, 175)',
fontFamily: 'Overpass, sans-serif',
fontSize: '18px',
lineHeight: '24px',
}}
>
<Container
style={{
width: '480px',
maxWidth: '100%',
padding: '10px',
margin: '0 auto',
}}
>
<Section
style={{
padding: '36px',
tableLayout: 'fixed',
backgroundColor: 'rgb(226, 232, 240)',
border: 'solid 0px rgb(248 113 113)',
borderRadius: '50px',
textAlign: 'center' as const,
}}
>
<Img
src="https://immich.app/img/immich-logo-inline-light.png"
alt="Immich"
style={{
height: 'auto',
margin: '0 auto 48px auto',
width: '50%',
alignSelf: 'center',
color: 'white',
}}
/>
<Text style={text}>
Hey <strong>{displayName}</strong>!
</Text>
<Text style={text}>A new account has been created for you.</Text>
<Text style={text}>
<strong>Username</strong>: {username}
{password && (
<>
<br />
<strong>Password</strong>: {password}
</>
)}
</Text>
<Row>
<Text style={{ ...text, marginBottom: '36px' }}>
To login, open the link in a browser, or click the button below.
</Text>
</Row>
<Row>
<Link style={{ marginTop: '50px' }} href={baseUrl}>
{baseUrl}
</Link>
</Row>
<Row>
<Button style={button} href={`${baseUrl}/auth/login`}>
Login
</Button>
</Row>
</Section>
<Hr style={{ color: 'rgb(66, 80, 175)', marginTop: '24px' }} />
<Section style={{ textAlign: 'center' }}>
<Row>
<Column align="center">
<Link href="https://play.google.com/store/apps/details?id=app.alextran.immich">
<Img src={`https://immich.app/img/google-play-badge.png`} height="96px" alt="Immich" />
</Link>
<Link href="https://apps.apple.com/sg/app/immich/id1613945652">
<Img
// TODO get this as a png
src={`https://immich.app/img/ios-app-store-badge.svg`}
alt="Immich"
style={{ height: '68px', padding: '14px' }}
/>
</Link>
</Column>
</Row>
</Section>
<Text
style={{
color: '#6a737d',
fontSize: '0.8rem',
textAlign: 'center' as const,
marginTop: '12px',
}}
>
<Link href="https://immich.app">Immich</Link> project is available under GNU AGPL v3 license.
</Text>
</Container>
</Body>
</Html>
);
WelcomeEmail.PreviewProps = {
baseUrl: 'https://demo.immich.app/auth/login',
displayName: 'Alan Turing',
username: 'alanturing',
password: 'mysuperpassword',
} as WelcomeEmailProps;
export default WelcomeEmail;
const text = {
margin: '0 0 24px 0',
textAlign: 'left' as const,
fontSize: '18px',
lineHeight: '24px',
};
const button: CSS.Properties = {
backgroundColor: 'rgb(66, 80, 175)',
margin: '1em 0',
padding: '0.75em 3em',
color: '#fff',
fontSize: '1em',
fontWeight: 700,
lineHeight: 1.5,
textTransform: 'uppercase',
borderRadius: '9999px',
};

View File

@ -80,6 +80,15 @@ export const SystemConfigKey = {
MAP_LIGHT_STYLE: 'map.lightStyle',
MAP_DARK_STYLE: 'map.darkStyle',
NOTIFICATIONS_SMTP_ENABLED: 'notifications.smtp.enabled',
NOTIFICATIONS_SMTP_FROM: 'notifications.smtp.from',
NOTIFICATIONS_SMTP_REPLY_TO: 'notifications.smtp.replyTo',
NOTIFICATIONS_SMTP_TRANSPORT_IGNORE_CERT: 'notifications.smtp.transport.ignoreCert',
NOTIFICATIONS_SMTP_TRANSPORT_HOST: 'notifications.smtp.transport.host',
NOTIFICATIONS_SMTP_TRANSPORT_PORT: 'notifications.smtp.transport.port',
NOTIFICATIONS_SMTP_TRANSPORT_USERNAME: 'notifications.smtp.transport.username',
NOTIFICATIONS_SMTP_TRANSPORT_PASSWORD: 'notifications.smtp.transport.password',
REVERSE_GEOCODING_ENABLED: 'reverseGeocoding.enabled',
NEW_VERSION_CHECK_ENABLED: 'newVersionCheck.enabled',
@ -306,6 +315,20 @@ export interface SystemConfig {
enabled: boolean;
};
};
notifications: {
smtp: {
enabled: boolean;
from: string;
replyTo: string;
transport: {
ignoreCert: boolean;
host: string;
port: number;
username: string;
password: string;
};
};
};
server: {
externalDomain: string;
loginPageMessage: string;

View File

@ -11,6 +11,7 @@ export enum QueueName {
SEARCH = 'search',
SIDECAR = 'sidecar',
LIBRARY = 'library',
NOTIFICATION = 'notifications',
}
export type ConcurrentQueueName = Exclude<
@ -90,6 +91,10 @@ export enum JobName {
SIDECAR_DISCOVERY = 'sidecar-discovery',
SIDECAR_SYNC = 'sidecar-sync',
SIDECAR_WRITE = 'sidecar-write',
// Notification
NOTIFY_SIGNUP = 'notify-signup',
SEND_EMAIL = 'notification-send-email',
}
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
@ -136,6 +141,17 @@ export interface IDeferrableJob extends IEntityJob {
deferred?: boolean;
}
export interface IEmailJob {
to: string;
subject: string;
html: string;
text: string;
}
export interface INotifySignupJob extends IEntityJob {
tempPassword?: string;
}
export interface JobCounts {
active: number;
completed: number;
@ -218,7 +234,11 @@ export type JobItem =
| { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob }
| { name: JobName.LIBRARY_DELETE; data: IEntityJob }
| { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob }
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob };
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }
// Notification
| { name: JobName.SEND_EMAIL; data: IEmailJob }
| { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob };
export enum JobStatus {
SUCCESS = 'success',

View File

@ -0,0 +1,44 @@
export const INotificationRepository = 'INotificationRepository';
export type SendEmailOptions = {
from: string;
to: string;
replyTo?: string;
subject: string;
html: string;
text: string;
smtp: SmtpOptions;
};
export type SmtpOptions = {
host: string;
port?: number;
username?: string;
password?: string;
ignoreCert?: boolean;
};
export enum EmailTemplate {
WELCOME = 'welcome',
RESET_PASSWORD = 'reset-password',
}
export interface WelcomeEmailProps {
baseUrl: string;
displayName: string;
username: string;
password?: string;
}
export type EmailRenderRequest = { template: EmailTemplate.WELCOME; data: WelcomeEmailProps };
export type SendEmailResponse = {
messageId: string;
response: any;
};
export interface INotificationRepository {
renderEmail(request: EmailRenderRequest): { html: string; text: string };
sendEmail(options: SendEmailOptions): Promise<SendEmailResponse>;
verifySmtp(options: SmtpOptions): Promise<true>;
}

View File

@ -19,6 +19,7 @@ import { IMemoryRepository } from 'src/interfaces/memory.interface';
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { INotificationRepository } from 'src/interfaces/notification.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
@ -51,6 +52,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MetricRepository } from 'src/repositories/metric.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { SearchRepository } from 'src/repositories/search.repository';
@ -84,6 +86,7 @@ export const repositories = [
{ provide: IMetadataRepository, useClass: MetadataRepository },
{ provide: IMetricRepository, useClass: MetricRepository },
{ provide: IMoveRepository, useClass: MoveRepository },
{ provide: INotificationRepository, useClass: NotificationRepository },
{ provide: IPartnerRepository, useClass: PartnerRepository },
{ provide: IPersonRepository, useClass: PersonRepository },
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },

View File

@ -78,6 +78,10 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY,
// Notification
[JobName.SEND_EMAIL]: QueueName.NOTIFICATION,
[JobName.NOTIFY_SIGNUP]: QueueName.NOTIFICATION,
};
@Instrumentation()

View File

@ -0,0 +1,72 @@
import { Inject, Injectable } from '@nestjs/common';
import { render } from '@react-email/render';
import { createTransport } from 'nodemailer';
import React from 'react';
import { WelcomeEmail } from 'src/emails/welcome.email';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import {
EmailRenderRequest,
EmailTemplate,
INotificationRepository,
SendEmailOptions,
SendEmailResponse,
SmtpOptions,
} from 'src/interfaces/notification.interface';
import { Instrumentation } from 'src/utils/instrumentation';
@Instrumentation()
@Injectable()
export class NotificationRepository implements INotificationRepository {
constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
this.logger.setContext(NotificationRepository.name);
}
verifySmtp(options: SmtpOptions): Promise<true> {
const transport = this.createTransport(options);
try {
return transport.verify();
} finally {
transport.close();
}
}
renderEmail(request: EmailRenderRequest): { html: string; text: string } {
const component = this.render(request);
const html = render(component, { pretty: true });
const text = render(component, { plainText: true });
return { html, text };
}
sendEmail({ to, from, subject, html, text, smtp }: SendEmailOptions): Promise<SendEmailResponse> {
this.logger.debug(`Sending email to ${to} with subject: ${subject}`);
const transport = this.createTransport(smtp);
try {
return transport.sendMail({ to, from, subject, html, text });
} finally {
transport.close();
}
}
private render({ template, data }: EmailRenderRequest): React.FunctionComponentElement<any> {
switch (template) {
case EmailTemplate.WELCOME: {
return React.createElement(WelcomeEmail, data);
}
}
}
private createTransport(options: SmtpOptions) {
return createTransport({
host: options.host,
port: options.port,
tls: { rejectUnauthorized: options.ignoreCert },
auth:
options.username || options.password
? {
user: options.username,
pass: options.password,
}
: undefined,
});
}
}

View File

@ -14,6 +14,7 @@ import { MediaService } from 'src/services/media.service';
import { MemoryService } from 'src/services/memory.service';
import { MetadataService } from 'src/services/metadata.service';
import { MicroservicesService } from 'src/services/microservices.service';
import { NotificationService } from 'src/services/notification.service';
import { PartnerService } from 'src/services/partner.service';
import { PersonService } from 'src/services/person.service';
import { SearchService } from 'src/services/search.service';
@ -48,6 +49,7 @@ export const services = [
MediaService,
MemoryService,
MetadataService,
NotificationService,
PartnerService,
PersonService,
SearchService,

View File

@ -120,6 +120,7 @@ describe(JobService.name, () => {
[QueueName.FACIAL_RECOGNITION]: expectedJobStatus,
[QueueName.SIDECAR]: expectedJobStatus,
[QueueName.LIBRARY]: expectedJobStatus,
[QueueName.NOTIFICATION]: expectedJobStatus,
});
});
});
@ -252,6 +253,7 @@ describe(JobService.name, () => {
[QueueName.MIGRATION]: { concurrency: 10 },
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 10 },
[QueueName.NOTIFICATION]: { concurrency: 5 },
},
} as SystemConfig);

View File

@ -7,6 +7,7 @@ import { JobService } from 'src/services/job.service';
import { LibraryService } from 'src/services/library.service';
import { MediaService } from 'src/services/media.service';
import { MetadataService } from 'src/services/metadata.service';
import { NotificationService } from 'src/services/notification.service';
import { PersonService } from 'src/services/person.service';
import { SessionService } from 'src/services/session.service';
import { SmartInfoService } from 'src/services/smart-info.service';
@ -22,23 +23,25 @@ export class MicroservicesService {
private auditService: AuditService,
private assetService: AssetService,
private configService: SystemConfigService,
private databaseService: DatabaseService,
private jobService: JobService,
private libraryService: LibraryService,
private mediaService: MediaService,
private metadataService: MetadataService,
private notificationService: NotificationService,
private personService: PersonService,
private smartInfoService: SmartInfoService,
private sessionService: SessionService,
private storageTemplateService: StorageTemplateService,
private storageService: StorageService,
private userService: UserService,
private databaseService: DatabaseService,
) {}
async init() {
await this.databaseService.init();
await this.configService.init();
await this.libraryService.init();
await this.notificationService.init();
await this.jobService.init({
[JobName.ASSET_DELETION]: (data) => this.assetService.handleAssetDeletion(data),
[JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(),
@ -80,6 +83,8 @@ export class MicroservicesService {
[JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data),
[JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data),
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
[JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data),
[JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data),
});
await this.metadataService.init();

View File

@ -0,0 +1,98 @@
import { Inject, Injectable } from '@nestjs/common';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnServerEvent } from 'src/decorators';
import { ServerAsyncEvent, ServerAsyncEventMap } from 'src/interfaces/event.interface';
import { IEmailJob, IJobRepository, INotifySignupJob, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
@Injectable()
export class NotificationService {
private configCore: SystemConfigCore;
constructor(
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(INotificationRepository) private notificationRepository: INotificationRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(NotificationService.name);
this.configCore = SystemConfigCore.create(configRepository, logger);
}
init() {
// TODO
return Promise.resolve();
}
@OnServerEvent(ServerAsyncEvent.CONFIG_VALIDATE)
async onValidateConfig({ newConfig }: ServerAsyncEventMap[ServerAsyncEvent.CONFIG_VALIDATE]) {
try {
if (newConfig.notifications.smtp.enabled) {
await this.notificationRepository.verifySmtp(newConfig.notifications.smtp.transport);
}
} catch (error: Error | any) {
this.logger.error(`Failed to validate SMTP configuration: ${error}`, error?.stack);
throw new Error(`Invalid SMTP configuration: ${error}`);
}
}
async handleUserSignup({ id, tempPassword }: INotifySignupJob) {
const user = await this.userRepository.get(id, { withDeleted: false });
if (!user) {
return JobStatus.SKIPPED;
}
const { server } = await this.configCore.getConfig();
const { html, text } = this.notificationRepository.renderEmail({
template: EmailTemplate.WELCOME,
data: {
baseUrl: server.externalDomain || 'http://localhost:2283',
displayName: user.name,
username: user.email,
password: tempPassword,
},
});
await this.jobRepository.queue({
name: JobName.SEND_EMAIL,
data: {
to: user.email,
subject: 'Welcome to Immich',
html,
text,
},
});
return JobStatus.SUCCESS;
}
async handleSendEmail(data: IEmailJob): Promise<JobStatus> {
const { notifications } = await this.configCore.getConfig();
if (!notifications.smtp.enabled) {
return JobStatus.SKIPPED;
}
const { to, subject, html, text: plain } = data;
const response = await this.notificationRepository.sendEmail({
to,
subject,
html,
text: plain,
from: notifications.smtp.from,
replyTo: notifications.smtp.replyTo || notifications.smtp.from,
smtp: notifications.smtp.transport,
});
if (!response) {
return JobStatus.FAILED;
}
this.logger.log(`Sent mail with id: ${response.messageId} status: ${response.response}`);
return JobStatus.SUCCESS;
}
}

View File

@ -186,6 +186,7 @@ describe(ServerInfoService.name, () => {
sidecar: true,
configFile: false,
trash: true,
email: false,
});
expect(configMock.load).toHaveBeenCalled();
});

View File

@ -44,6 +44,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
[QueueName.MIGRATION]: { concurrency: 5 },
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
[QueueName.NOTIFICATION]: { concurrency: 5 },
},
ffmpeg: {
crf: 30,
@ -153,6 +154,20 @@ const updatedConfig = Object.freeze<SystemConfig>({
user: {
deleteDelay: 15,
},
notifications: {
smtp: {
enabled: false,
from: '',
replyTo: '',
transport: {
host: '',
port: 587,
username: '',
password: '',
ignoreCert: false,
},
},
},
});
describe(SystemConfigService.name, () => {

View File

@ -60,8 +60,13 @@ export class UserService {
return this.findOrFail(auth.user.id, {}).then(mapUser);
}
create(createUserDto: CreateUserDto): Promise<UserResponseDto> {
return this.userCore.createUser(createUserDto).then(mapUser);
async create(dto: CreateUserDto): Promise<UserResponseDto> {
const user = await this.userCore.createUser(dto);
const tempPassword = user.shouldChangePassword ? dto.password : undefined;
if (dto.notify) {
await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id: user.id, tempPassword } });
}
return mapUser(user);
}
async update(auth: AuthDto, dto: UpdateUserDto): Promise<UserResponseDto> {

View File

@ -0,0 +1,10 @@
import { INotificationRepository } from 'src/interfaces/notification.interface';
import { Mocked } from 'vitest';
export const newNotificationRepositoryMock = (): Mocked<INotificationRepository> => {
return {
renderEmail: vitest.fn(),
sendEmail: vitest.fn(),
verifySmtp: vitest.fn(),
};
};

View File

@ -17,6 +17,7 @@
"esModuleInterop": true,
"preserveWatchOutput": true,
"baseUrl": "./",
"jsx": "react",
"types": ["vitest/globals"]
},
"exclude": ["dist", "node_modules", "upload"]

View File

@ -0,0 +1,109 @@
<script lang="ts">
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited
export let disabled = false;
const dispatch = createEventDispatcher<SettingsEventType>();
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault class="mt-4">
<div class="flex flex-col gap-4">
<SettingAccordion key="email" title="Email" subtitle="Settings for sending email notifications">
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
id="enable-smtp"
title="Enabled"
subtitle="Enable email notifications"
{disabled}
bind:checked={config.notifications.smtp.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required
label="Host"
desc="Host of the email server (e.g. smtp.immich.app)"
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.host}
isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
required
label="Port"
desc="Port of the email server (e.g 25, 465, or 587)"
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.port}
isEdited={config.notifications.smtp.transport.port !== savedConfig.notifications.smtp.transport.port}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="Username"
desc="Username to use when authenticating with the email server"
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.username}
isEdited={config.notifications.smtp.transport.username !==
savedConfig.notifications.smtp.transport.username}
/>
<SettingInputField
inputType={SettingInputFieldType.PASSWORD}
label="Password"
desc="Password to use when authenticating with the email server"
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.password}
isEdited={config.notifications.smtp.transport.password !==
savedConfig.notifications.smtp.transport.password}
/>
<SettingSwitch
id="enable-ignore-cert"
title="Ignore certificate errors"
subtitle="Ignore TLS certificate validation errors (not recommended)"
disabled={disabled || !config.notifications.smtp.enabled}
bind:checked={config.notifications.smtp.transport.ignoreCert}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required
label="From address"
desc="Sender email address, for example: &quot;Immich Photo Server <noreply@immich.app>&quot;"
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.from}
isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from}
/>
</div>
</SettingAccordion>
</div>
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['notifications'] })}
on:save={() => dispatch('save', { notifications: config.notifications })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</form>
</div>
</div>

View File

@ -8,6 +8,7 @@
import PasswordField from '../shared-components/password-field.svelte';
import Slider from '../elements/slider.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
export let onClose: () => void;
@ -19,6 +20,7 @@
let confirmPassword = '';
let name = '';
let shouldChangePassword = true;
let notify = true;
let canCreateUser = false;
let quotaSize: number | undefined;
@ -54,6 +56,7 @@
shouldChangePassword,
name,
quotaSizeInBytes,
notify,
},
});
@ -78,6 +81,13 @@
<input class="immich-form-input" id="email" bind:value={email} type="email" required />
</div>
{#if $featureFlags.email}
<div class="my-4 flex place-items-center justify-between gap-2">
<label class="text-sm dark:text-immich-dark-fg" for="send-welcome-email"> Send welcome email </label>
<Slider id="send-welcome-email" bind:checked={notify} />
</div>
{/if}
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Password</label>
<PasswordField id="password" bind:password autocomplete="new-password" />

View File

@ -16,6 +16,7 @@ export const featureFlags = writable<FeatureFlags>({
passwordLogin: true,
configFile: false,
trash: true,
email: false,
});
export type ServerConfig = ServerConfigDto & { loaded: boolean };

View File

@ -124,6 +124,7 @@ export const getJobName = (jobName: JobName) => {
[JobName.BackgroundTask]: 'Background Tasks',
[JobName.Search]: 'Search',
[JobName.Library]: 'Library',
[JobName.Notifications]: 'Notifications',
};
return names[jobName];

View File

@ -10,6 +10,7 @@
import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte';
import PasswordLoginSettings from '$lib/components/admin-page/settings/password-login/password-login-settings.svelte';
import ServerSettings from '$lib/components/admin-page/settings/server/server-settings.svelte';
import NotificationSettings from '$lib/components/admin-page/settings/notification-settings/notification-settings.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import StorageTemplateSettings from '$lib/components/admin-page/settings/storage-template/storage-template-settings.svelte';
import ThemeSettings from '$lib/components/admin-page/settings/theme/theme-settings.svelte';
@ -48,6 +49,7 @@
| typeof ImageSettings
| typeof TrashSettings
| typeof NewVersionCheckSettings
| typeof NotificationSettings
| typeof FFmpegSettings
| typeof UserSettings;
@ -116,6 +118,12 @@
subtitle: 'Manage map related features and setting',
key: 'location',
},
{
item: NotificationSettings,
title: 'Notification Settings',
subtitle: 'Manage notification settings, including email',
key: 'notifications',
},
{
item: OAuthSettings,
title: 'OAuth Authentication',