mirror of
https://github.com/immich-app/immich.git
synced 2025-01-01 08:31:59 +00:00
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:
parent
4b86c7a298
commit
9bce3417e9
60 changed files with 5880 additions and 366 deletions
|
@ -75,6 +75,7 @@ describe('/server-info', () => {
|
||||||
search: true,
|
search: true,
|
||||||
sidecar: true,
|
sidecar: true,
|
||||||
trash: true,
|
trash: true,
|
||||||
|
email: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
9
mobile/openapi/.openapi-generator/FILES
generated
9
mobile/openapi/.openapi-generator/FILES
generated
|
@ -170,10 +170,13 @@ doc/SystemConfigLoggingDto.md
|
||||||
doc/SystemConfigMachineLearningDto.md
|
doc/SystemConfigMachineLearningDto.md
|
||||||
doc/SystemConfigMapDto.md
|
doc/SystemConfigMapDto.md
|
||||||
doc/SystemConfigNewVersionCheckDto.md
|
doc/SystemConfigNewVersionCheckDto.md
|
||||||
|
doc/SystemConfigNotificationsDto.md
|
||||||
doc/SystemConfigOAuthDto.md
|
doc/SystemConfigOAuthDto.md
|
||||||
doc/SystemConfigPasswordLoginDto.md
|
doc/SystemConfigPasswordLoginDto.md
|
||||||
doc/SystemConfigReverseGeocodingDto.md
|
doc/SystemConfigReverseGeocodingDto.md
|
||||||
doc/SystemConfigServerDto.md
|
doc/SystemConfigServerDto.md
|
||||||
|
doc/SystemConfigSmtpDto.md
|
||||||
|
doc/SystemConfigSmtpTransportDto.md
|
||||||
doc/SystemConfigStorageTemplateDto.md
|
doc/SystemConfigStorageTemplateDto.md
|
||||||
doc/SystemConfigTemplateStorageOptionDto.md
|
doc/SystemConfigTemplateStorageOptionDto.md
|
||||||
doc/SystemConfigThemeDto.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_machine_learning_dto.dart
|
||||||
lib/model/system_config_map_dto.dart
|
lib/model/system_config_map_dto.dart
|
||||||
lib/model/system_config_new_version_check_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_o_auth_dto.dart
|
||||||
lib/model/system_config_password_login_dto.dart
|
lib/model/system_config_password_login_dto.dart
|
||||||
lib/model/system_config_reverse_geocoding_dto.dart
|
lib/model/system_config_reverse_geocoding_dto.dart
|
||||||
lib/model/system_config_server_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_storage_template_dto.dart
|
||||||
lib/model/system_config_template_storage_option_dto.dart
|
lib/model/system_config_template_storage_option_dto.dart
|
||||||
lib/model/system_config_theme_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_machine_learning_dto_test.dart
|
||||||
test/system_config_map_dto_test.dart
|
test/system_config_map_dto_test.dart
|
||||||
test/system_config_new_version_check_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_o_auth_dto_test.dart
|
||||||
test/system_config_password_login_dto_test.dart
|
test/system_config_password_login_dto_test.dart
|
||||||
test/system_config_reverse_geocoding_dto_test.dart
|
test/system_config_reverse_geocoding_dto_test.dart
|
||||||
test/system_config_server_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_storage_template_dto_test.dart
|
||||||
test/system_config_template_storage_option_dto_test.dart
|
test/system_config_template_storage_option_dto_test.dart
|
||||||
test/system_config_theme_dto_test.dart
|
test/system_config_theme_dto_test.dart
|
||||||
|
|
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/AllJobStatusResponseDto.md
generated
BIN
mobile/openapi/doc/AllJobStatusResponseDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/CreateUserDto.md
generated
BIN
mobile/openapi/doc/CreateUserDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/ServerFeaturesDto.md
generated
BIN
mobile/openapi/doc/ServerFeaturesDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigDto.md
generated
BIN
mobile/openapi/doc/SystemConfigDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigJobDto.md
generated
BIN
mobile/openapi/doc/SystemConfigJobDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigNotificationsDto.md
generated
Normal file
BIN
mobile/openapi/doc/SystemConfigNotificationsDto.md
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigSmtpDto.md
generated
Normal file
BIN
mobile/openapi/doc/SystemConfigSmtpDto.md
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigSmtpTransportDto.md
generated
Normal file
BIN
mobile/openapi/doc/SystemConfigSmtpTransportDto.md
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/model/create_user_dto.dart
generated
BIN
mobile/openapi/lib/model/create_user_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/job_name.dart
generated
BIN
mobile/openapi/lib/model/job_name.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/server_features_dto.dart
generated
BIN
mobile/openapi/lib/model/server_features_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_job_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_job_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_notifications_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/system_config_notifications_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_smtp_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/system_config_smtp_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_smtp_transport_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/system_config_smtp_transport_dto.dart
generated
Normal file
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/test/create_user_dto_test.dart
generated
BIN
mobile/openapi/test/create_user_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/server_features_dto_test.dart
generated
BIN
mobile/openapi/test/server_features_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/system_config_dto_test.dart
generated
BIN
mobile/openapi/test/system_config_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/system_config_job_dto_test.dart
generated
BIN
mobile/openapi/test/system_config_job_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/system_config_notifications_dto_test.dart
generated
Normal file
BIN
mobile/openapi/test/system_config_notifications_dto_test.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/test/system_config_smtp_dto_test.dart
generated
Normal file
BIN
mobile/openapi/test/system_config_smtp_dto_test.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/test/system_config_smtp_transport_dto_test.dart
generated
Normal file
BIN
mobile/openapi/test/system_config_smtp_transport_dto_test.dart
generated
Normal file
Binary file not shown.
|
@ -6812,6 +6812,9 @@
|
||||||
"migration": {
|
"migration": {
|
||||||
"$ref": "#/components/schemas/JobStatusDto"
|
"$ref": "#/components/schemas/JobStatusDto"
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"$ref": "#/components/schemas/JobStatusDto"
|
||||||
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"$ref": "#/components/schemas/JobStatusDto"
|
"$ref": "#/components/schemas/JobStatusDto"
|
||||||
},
|
},
|
||||||
|
@ -6838,6 +6841,7 @@
|
||||||
"library",
|
"library",
|
||||||
"metadataExtraction",
|
"metadataExtraction",
|
||||||
"migration",
|
"migration",
|
||||||
|
"notifications",
|
||||||
"search",
|
"search",
|
||||||
"sidecar",
|
"sidecar",
|
||||||
"smartSearch",
|
"smartSearch",
|
||||||
|
@ -7754,6 +7758,9 @@
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"notify": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
@ -8145,7 +8152,8 @@
|
||||||
"migration",
|
"migration",
|
||||||
"search",
|
"search",
|
||||||
"sidecar",
|
"sidecar",
|
||||||
"library"
|
"library",
|
||||||
|
"notifications"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
@ -9357,6 +9365,9 @@
|
||||||
"configFile": {
|
"configFile": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"facialRecognition": {
|
"facialRecognition": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
@ -9390,6 +9401,7 @@
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"configFile",
|
"configFile",
|
||||||
|
"email",
|
||||||
"facialRecognition",
|
"facialRecognition",
|
||||||
"map",
|
"map",
|
||||||
"oauth",
|
"oauth",
|
||||||
|
@ -9925,6 +9937,9 @@
|
||||||
"newVersionCheck": {
|
"newVersionCheck": {
|
||||||
"$ref": "#/components/schemas/SystemConfigNewVersionCheckDto"
|
"$ref": "#/components/schemas/SystemConfigNewVersionCheckDto"
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"$ref": "#/components/schemas/SystemConfigNotificationsDto"
|
||||||
|
},
|
||||||
"oauth": {
|
"oauth": {
|
||||||
"$ref": "#/components/schemas/SystemConfigOAuthDto"
|
"$ref": "#/components/schemas/SystemConfigOAuthDto"
|
||||||
},
|
},
|
||||||
|
@ -9959,6 +9974,7 @@
|
||||||
"machineLearning",
|
"machineLearning",
|
||||||
"map",
|
"map",
|
||||||
"newVersionCheck",
|
"newVersionCheck",
|
||||||
|
"notifications",
|
||||||
"oauth",
|
"oauth",
|
||||||
"passwordLogin",
|
"passwordLogin",
|
||||||
"reverseGeocoding",
|
"reverseGeocoding",
|
||||||
|
@ -10128,6 +10144,9 @@
|
||||||
"migration": {
|
"migration": {
|
||||||
"$ref": "#/components/schemas/JobSettingsDto"
|
"$ref": "#/components/schemas/JobSettingsDto"
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"$ref": "#/components/schemas/JobSettingsDto"
|
||||||
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"$ref": "#/components/schemas/JobSettingsDto"
|
"$ref": "#/components/schemas/JobSettingsDto"
|
||||||
},
|
},
|
||||||
|
@ -10150,6 +10169,7 @@
|
||||||
"library",
|
"library",
|
||||||
"metadataExtraction",
|
"metadataExtraction",
|
||||||
"migration",
|
"migration",
|
||||||
|
"notifications",
|
||||||
"search",
|
"search",
|
||||||
"sidecar",
|
"sidecar",
|
||||||
"smartSearch",
|
"smartSearch",
|
||||||
|
@ -10267,6 +10287,17 @@
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SystemConfigNotificationsDto": {
|
||||||
|
"properties": {
|
||||||
|
"smtp": {
|
||||||
|
"$ref": "#/components/schemas/SystemConfigSmtpDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"smtp"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SystemConfigOAuthDto": {
|
"SystemConfigOAuthDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"autoLaunch": {
|
"autoLaunch": {
|
||||||
|
@ -10368,6 +10399,58 @@
|
||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"SystemConfigStorageTemplateDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
|
|
|
@ -407,6 +407,7 @@ export type AllJobStatusResponseDto = {
|
||||||
library: JobStatusDto;
|
library: JobStatusDto;
|
||||||
metadataExtraction: JobStatusDto;
|
metadataExtraction: JobStatusDto;
|
||||||
migration: JobStatusDto;
|
migration: JobStatusDto;
|
||||||
|
notifications: JobStatusDto;
|
||||||
search: JobStatusDto;
|
search: JobStatusDto;
|
||||||
sidecar: JobStatusDto;
|
sidecar: JobStatusDto;
|
||||||
smartSearch: JobStatusDto;
|
smartSearch: JobStatusDto;
|
||||||
|
@ -745,6 +746,7 @@ export type ServerConfigDto = {
|
||||||
};
|
};
|
||||||
export type ServerFeaturesDto = {
|
export type ServerFeaturesDto = {
|
||||||
configFile: boolean;
|
configFile: boolean;
|
||||||
|
email: boolean;
|
||||||
facialRecognition: boolean;
|
facialRecognition: boolean;
|
||||||
map: boolean;
|
map: boolean;
|
||||||
oauth: boolean;
|
oauth: boolean;
|
||||||
|
@ -895,6 +897,7 @@ export type SystemConfigJobDto = {
|
||||||
library: JobSettingsDto;
|
library: JobSettingsDto;
|
||||||
metadataExtraction: JobSettingsDto;
|
metadataExtraction: JobSettingsDto;
|
||||||
migration: JobSettingsDto;
|
migration: JobSettingsDto;
|
||||||
|
notifications: JobSettingsDto;
|
||||||
search: JobSettingsDto;
|
search: JobSettingsDto;
|
||||||
sidecar: JobSettingsDto;
|
sidecar: JobSettingsDto;
|
||||||
smartSearch: JobSettingsDto;
|
smartSearch: JobSettingsDto;
|
||||||
|
@ -944,6 +947,22 @@ export type SystemConfigMapDto = {
|
||||||
export type SystemConfigNewVersionCheckDto = {
|
export type SystemConfigNewVersionCheckDto = {
|
||||||
enabled: boolean;
|
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 = {
|
export type SystemConfigOAuthDto = {
|
||||||
autoLaunch: boolean;
|
autoLaunch: boolean;
|
||||||
autoRegister: boolean;
|
autoRegister: boolean;
|
||||||
|
@ -994,6 +1013,7 @@ export type SystemConfigDto = {
|
||||||
machineLearning: SystemConfigMachineLearningDto;
|
machineLearning: SystemConfigMachineLearningDto;
|
||||||
map: SystemConfigMapDto;
|
map: SystemConfigMapDto;
|
||||||
newVersionCheck: SystemConfigNewVersionCheckDto;
|
newVersionCheck: SystemConfigNewVersionCheckDto;
|
||||||
|
notifications: SystemConfigNotificationsDto;
|
||||||
oauth: SystemConfigOAuthDto;
|
oauth: SystemConfigOAuthDto;
|
||||||
passwordLogin: SystemConfigPasswordLoginDto;
|
passwordLogin: SystemConfigPasswordLoginDto;
|
||||||
reverseGeocoding: SystemConfigReverseGeocodingDto;
|
reverseGeocoding: SystemConfigReverseGeocodingDto;
|
||||||
|
@ -1035,6 +1055,7 @@ export type CreateUserDto = {
|
||||||
email: string;
|
email: string;
|
||||||
memoriesEnabled?: boolean;
|
memoriesEnabled?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
|
notify?: boolean;
|
||||||
password: string;
|
password: string;
|
||||||
quotaSizeInBytes?: number | null;
|
quotaSizeInBytes?: number | null;
|
||||||
shouldChangePassword?: boolean;
|
shouldChangePassword?: boolean;
|
||||||
|
@ -2852,7 +2873,8 @@ export enum JobName {
|
||||||
Migration = "migration",
|
Migration = "migration",
|
||||||
Search = "search",
|
Search = "search",
|
||||||
Sidecar = "sidecar",
|
Sidecar = "sidecar",
|
||||||
Library = "library"
|
Library = "library",
|
||||||
|
Notifications = "notifications"
|
||||||
}
|
}
|
||||||
export enum JobCommand {
|
export enum JobCommand {
|
||||||
Start = "start",
|
Start = "start",
|
||||||
|
|
5431
server/package-lock.json
generated
5431
server/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -29,7 +29,8 @@
|
||||||
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/database.config.js",
|
"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: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",
|
"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": {
|
"dependencies": {
|
||||||
"@nestjs/bullmq": "^10.0.1",
|
"@nestjs/bullmq": "^10.0.1",
|
||||||
|
@ -47,6 +48,7 @@
|
||||||
"@opentelemetry/context-async-hooks": "^1.24.0",
|
"@opentelemetry/context-async-hooks": "^1.24.0",
|
||||||
"@opentelemetry/exporter-prometheus": "^0.51.0",
|
"@opentelemetry/exporter-prometheus": "^0.51.0",
|
||||||
"@opentelemetry/sdk-node": "^0.51.0",
|
"@opentelemetry/sdk-node": "^0.51.0",
|
||||||
|
"@react-email/components": "^0.0.17",
|
||||||
"@socket.io/postgres-adapter": "^0.3.1",
|
"@socket.io/postgres-adapter": "^0.3.1",
|
||||||
"archiver": "^7.0.0",
|
"archiver": "^7.0.0",
|
||||||
"async-lock": "^1.4.0",
|
"async-lock": "^1.4.0",
|
||||||
|
@ -72,9 +74,11 @@
|
||||||
"nest-commander": "^3.11.1",
|
"nest-commander": "^3.11.1",
|
||||||
"nestjs-cls": "^4.3.0",
|
"nestjs-cls": "^4.3.0",
|
||||||
"nestjs-otel": "^5.1.5",
|
"nestjs-otel": "^5.1.5",
|
||||||
|
"nodemailer": "^6.9.13",
|
||||||
"openid-client": "^5.4.3",
|
"openid-client": "^5.4.3",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"picomatch": "^4.0.0",
|
"picomatch": "^4.0.0",
|
||||||
|
"react-email": "^2.1.2",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
|
@ -102,7 +106,9 @@
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^20.5.7",
|
"@types/node": "^20.5.7",
|
||||||
|
"@types/nodemailer": "^6.4.14",
|
||||||
"@types/picomatch": "^2.3.3",
|
"@types/picomatch": "^2.3.3",
|
||||||
|
"@types/supertest": "^6.0.0",
|
||||||
"@types/ua-parser-js": "^0.7.36",
|
"@types/ua-parser-js": "^0.7.36",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
"@typescript-eslint/parser": "^7.0.0",
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
|
|
|
@ -61,6 +61,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||||
[QueueName.MIGRATION]: { concurrency: 5 },
|
[QueueName.MIGRATION]: { concurrency: 5 },
|
||||||
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
|
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
|
||||||
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
|
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
|
||||||
|
[QueueName.NOTIFICATION]: { concurrency: 5 },
|
||||||
},
|
},
|
||||||
logging: {
|
logging: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
@ -145,6 +146,20 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||||
externalDomain: '',
|
externalDomain: '',
|
||||||
loginPageMessage: '',
|
loginPageMessage: '',
|
||||||
},
|
},
|
||||||
|
notifications: {
|
||||||
|
smtp: {
|
||||||
|
enabled: false,
|
||||||
|
from: '',
|
||||||
|
replyTo: '',
|
||||||
|
transport: {
|
||||||
|
ignoreCert: false,
|
||||||
|
host: '',
|
||||||
|
port: 587,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
deleteDelay: 7,
|
deleteDelay: 7,
|
||||||
},
|
},
|
||||||
|
@ -162,6 +177,7 @@ export enum FeatureFlag {
|
||||||
PASSWORD_LOGIN = 'passwordLogin',
|
PASSWORD_LOGIN = 'passwordLogin',
|
||||||
CONFIG_FILE = 'configFile',
|
CONFIG_FILE = 'configFile',
|
||||||
TRASH = 'trash',
|
TRASH = 'trash',
|
||||||
|
EMAIL = 'email',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeatureFlags = Record<FeatureFlag, boolean>;
|
export type FeatureFlags = Record<FeatureFlag, boolean>;
|
||||||
|
@ -243,6 +259,7 @@ export class SystemConfigCore {
|
||||||
[FeatureFlag.OAUTH_AUTO_LAUNCH]: config.oauth.autoLaunch,
|
[FeatureFlag.OAUTH_AUTO_LAUNCH]: config.oauth.autoLaunch,
|
||||||
[FeatureFlag.PASSWORD_LOGIN]: config.passwordLogin.enabled,
|
[FeatureFlag.PASSWORD_LOGIN]: config.passwordLogin.enabled,
|
||||||
[FeatureFlag.CONFIG_FILE]: !!process.env.IMMICH_CONFIG_FILE,
|
[FeatureFlag.CONFIG_FILE]: !!process.env.IMMICH_CONFIG_FILE,
|
||||||
|
[FeatureFlag.EMAIL]: config.notifications.smtp.enabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -84,4 +84,7 @@ export class AllJobStatusResponseDto implements Record<QueueName, JobStatusDto>
|
||||||
|
|
||||||
@ApiProperty({ type: JobStatusDto })
|
@ApiProperty({ type: JobStatusDto })
|
||||||
[QueueName.LIBRARY]!: JobStatusDto;
|
[QueueName.LIBRARY]!: JobStatusDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: JobStatusDto })
|
||||||
|
[QueueName.NOTIFICATION]!: JobStatusDto;
|
||||||
}
|
}
|
||||||
|
|
|
@ -108,6 +108,7 @@ export class ServerFeaturesDto implements FeatureFlags {
|
||||||
passwordLogin!: boolean;
|
passwordLogin!: boolean;
|
||||||
sidecar!: boolean;
|
sidecar!: boolean;
|
||||||
search!: boolean;
|
search!: boolean;
|
||||||
|
email!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReleaseNotification {
|
export interface ReleaseNotification {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
@ -43,6 +44,7 @@ class CronValidator implements ValidatorConstraintInterface {
|
||||||
const isLibraryScanEnabled = (config: SystemConfigLibraryScanDto) => config.enabled;
|
const isLibraryScanEnabled = (config: SystemConfigLibraryScanDto) => config.enabled;
|
||||||
const isOAuthEnabled = (config: SystemConfigOAuthDto) => config.enabled;
|
const isOAuthEnabled = (config: SystemConfigOAuthDto) => config.enabled;
|
||||||
const isOAuthOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled;
|
const isOAuthOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled;
|
||||||
|
const isEmailNotificationEnabled = (config: SystemConfigSmtpDto) => config.enabled;
|
||||||
|
|
||||||
export class SystemConfigFFmpegDto {
|
export class SystemConfigFFmpegDto {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
@ -202,6 +204,12 @@ class SystemConfigJobDto implements Record<ConcurrentQueueName, JobSettingsDto>
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@Type(() => JobSettingsDto)
|
@Type(() => JobSettingsDto)
|
||||||
[QueueName.LIBRARY]!: JobSettingsDto;
|
[QueueName.LIBRARY]!: JobSettingsDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: JobSettingsDto })
|
||||||
|
@ValidateNested()
|
||||||
|
@IsObject()
|
||||||
|
@Type(() => JobSettingsDto)
|
||||||
|
[QueueName.NOTIFICATION]!: JobSettingsDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SystemConfigLibraryScanDto {
|
class SystemConfigLibraryScanDto {
|
||||||
|
@ -358,6 +366,53 @@ class SystemConfigServerDto {
|
||||||
loginPageMessage!: string;
|
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 {
|
class SystemConfigStorageTemplateDto {
|
||||||
@ValidateBoolean()
|
@ValidateBoolean()
|
||||||
enabled!: boolean;
|
enabled!: boolean;
|
||||||
|
@ -512,6 +567,11 @@ export class SystemConfigDto implements SystemConfig {
|
||||||
@IsObject()
|
@IsObject()
|
||||||
library!: SystemConfigLibraryDto;
|
library!: SystemConfigLibraryDto;
|
||||||
|
|
||||||
|
@Type(() => SystemConfigNotificationsDto)
|
||||||
|
@ValidateNested()
|
||||||
|
@IsObject()
|
||||||
|
notifications!: SystemConfigNotificationsDto;
|
||||||
|
|
||||||
@Type(() => SystemConfigServerDto)
|
@Type(() => SystemConfigServerDto)
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
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 { getRandomAvatarColor } from 'src/dtos/user-profile.dto';
|
||||||
import { UserAvatarColor, UserEntity, UserStatus } from 'src/entities/user.entity';
|
import { UserAvatarColor, UserEntity, UserStatus } from 'src/entities/user.entity';
|
||||||
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
|
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
|
||||||
|
@ -34,6 +34,10 @@ export class CreateUserDto {
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
@ValidateBoolean({ optional: true })
|
||||||
shouldChangePassword?: boolean;
|
shouldChangePassword?: boolean;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsBoolean()
|
||||||
|
notify?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateAdminDto {
|
export class CreateAdminDto {
|
||||||
|
|
159
server/src/emails/welcome.email.tsx
Normal file
159
server/src/emails/welcome.email.tsx
Normal 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',
|
||||||
|
};
|
|
@ -80,6 +80,15 @@ export const SystemConfigKey = {
|
||||||
MAP_LIGHT_STYLE: 'map.lightStyle',
|
MAP_LIGHT_STYLE: 'map.lightStyle',
|
||||||
MAP_DARK_STYLE: 'map.darkStyle',
|
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',
|
REVERSE_GEOCODING_ENABLED: 'reverseGeocoding.enabled',
|
||||||
|
|
||||||
NEW_VERSION_CHECK_ENABLED: 'newVersionCheck.enabled',
|
NEW_VERSION_CHECK_ENABLED: 'newVersionCheck.enabled',
|
||||||
|
@ -306,6 +315,20 @@ export interface SystemConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
notifications: {
|
||||||
|
smtp: {
|
||||||
|
enabled: boolean;
|
||||||
|
from: string;
|
||||||
|
replyTo: string;
|
||||||
|
transport: {
|
||||||
|
ignoreCert: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
server: {
|
server: {
|
||||||
externalDomain: string;
|
externalDomain: string;
|
||||||
loginPageMessage: string;
|
loginPageMessage: string;
|
||||||
|
|
|
@ -11,6 +11,7 @@ export enum QueueName {
|
||||||
SEARCH = 'search',
|
SEARCH = 'search',
|
||||||
SIDECAR = 'sidecar',
|
SIDECAR = 'sidecar',
|
||||||
LIBRARY = 'library',
|
LIBRARY = 'library',
|
||||||
|
NOTIFICATION = 'notifications',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConcurrentQueueName = Exclude<
|
export type ConcurrentQueueName = Exclude<
|
||||||
|
@ -90,6 +91,10 @@ export enum JobName {
|
||||||
SIDECAR_DISCOVERY = 'sidecar-discovery',
|
SIDECAR_DISCOVERY = 'sidecar-discovery',
|
||||||
SIDECAR_SYNC = 'sidecar-sync',
|
SIDECAR_SYNC = 'sidecar-sync',
|
||||||
SIDECAR_WRITE = 'sidecar-write',
|
SIDECAR_WRITE = 'sidecar-write',
|
||||||
|
|
||||||
|
// Notification
|
||||||
|
NOTIFY_SIGNUP = 'notify-signup',
|
||||||
|
SEND_EMAIL = 'notification-send-email',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
|
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
|
||||||
|
@ -136,6 +141,17 @@ export interface IDeferrableJob extends IEntityJob {
|
||||||
deferred?: boolean;
|
deferred?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IEmailJob {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INotifySignupJob extends IEntityJob {
|
||||||
|
tempPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JobCounts {
|
export interface JobCounts {
|
||||||
active: number;
|
active: number;
|
||||||
completed: number;
|
completed: number;
|
||||||
|
@ -218,7 +234,11 @@ export type JobItem =
|
||||||
| { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob }
|
| { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob }
|
||||||
| { name: JobName.LIBRARY_DELETE; data: IEntityJob }
|
| { name: JobName.LIBRARY_DELETE; data: IEntityJob }
|
||||||
| { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob }
|
| { 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 {
|
export enum JobStatus {
|
||||||
SUCCESS = 'success',
|
SUCCESS = 'success',
|
||||||
|
|
44
server/src/interfaces/notification.interface.ts
Normal file
44
server/src/interfaces/notification.interface.ts
Normal 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>;
|
||||||
|
}
|
|
@ -19,6 +19,7 @@ import { IMemoryRepository } from 'src/interfaces/memory.interface';
|
||||||
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
|
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
|
||||||
import { IMetricRepository } from 'src/interfaces/metric.interface';
|
import { IMetricRepository } from 'src/interfaces/metric.interface';
|
||||||
import { IMoveRepository } from 'src/interfaces/move.interface';
|
import { IMoveRepository } from 'src/interfaces/move.interface';
|
||||||
|
import { INotificationRepository } from 'src/interfaces/notification.interface';
|
||||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||||
import { IPersonRepository } from 'src/interfaces/person.interface';
|
import { IPersonRepository } from 'src/interfaces/person.interface';
|
||||||
import { ISearchRepository } from 'src/interfaces/search.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 { MetadataRepository } from 'src/repositories/metadata.repository';
|
||||||
import { MetricRepository } from 'src/repositories/metric.repository';
|
import { MetricRepository } from 'src/repositories/metric.repository';
|
||||||
import { MoveRepository } from 'src/repositories/move.repository';
|
import { MoveRepository } from 'src/repositories/move.repository';
|
||||||
|
import { NotificationRepository } from 'src/repositories/notification.repository';
|
||||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||||
import { PersonRepository } from 'src/repositories/person.repository';
|
import { PersonRepository } from 'src/repositories/person.repository';
|
||||||
import { SearchRepository } from 'src/repositories/search.repository';
|
import { SearchRepository } from 'src/repositories/search.repository';
|
||||||
|
@ -84,6 +86,7 @@ export const repositories = [
|
||||||
{ provide: IMetadataRepository, useClass: MetadataRepository },
|
{ provide: IMetadataRepository, useClass: MetadataRepository },
|
||||||
{ provide: IMetricRepository, useClass: MetricRepository },
|
{ provide: IMetricRepository, useClass: MetricRepository },
|
||||||
{ provide: IMoveRepository, useClass: MoveRepository },
|
{ provide: IMoveRepository, useClass: MoveRepository },
|
||||||
|
{ provide: INotificationRepository, useClass: NotificationRepository },
|
||||||
{ provide: IPartnerRepository, useClass: PartnerRepository },
|
{ provide: IPartnerRepository, useClass: PartnerRepository },
|
||||||
{ provide: IPersonRepository, useClass: PersonRepository },
|
{ provide: IPersonRepository, useClass: PersonRepository },
|
||||||
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
|
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
|
||||||
|
|
|
@ -78,6 +78,10 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
||||||
[JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY,
|
[JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY,
|
||||||
[JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY,
|
[JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY,
|
||||||
[JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY,
|
[JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY,
|
||||||
|
|
||||||
|
// Notification
|
||||||
|
[JobName.SEND_EMAIL]: QueueName.NOTIFICATION,
|
||||||
|
[JobName.NOTIFY_SIGNUP]: QueueName.NOTIFICATION,
|
||||||
};
|
};
|
||||||
|
|
||||||
@Instrumentation()
|
@Instrumentation()
|
||||||
|
|
72
server/src/repositories/notification.repository.ts
Normal file
72
server/src/repositories/notification.repository.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ import { MediaService } from 'src/services/media.service';
|
||||||
import { MemoryService } from 'src/services/memory.service';
|
import { MemoryService } from 'src/services/memory.service';
|
||||||
import { MetadataService } from 'src/services/metadata.service';
|
import { MetadataService } from 'src/services/metadata.service';
|
||||||
import { MicroservicesService } from 'src/services/microservices.service';
|
import { MicroservicesService } from 'src/services/microservices.service';
|
||||||
|
import { NotificationService } from 'src/services/notification.service';
|
||||||
import { PartnerService } from 'src/services/partner.service';
|
import { PartnerService } from 'src/services/partner.service';
|
||||||
import { PersonService } from 'src/services/person.service';
|
import { PersonService } from 'src/services/person.service';
|
||||||
import { SearchService } from 'src/services/search.service';
|
import { SearchService } from 'src/services/search.service';
|
||||||
|
@ -48,6 +49,7 @@ export const services = [
|
||||||
MediaService,
|
MediaService,
|
||||||
MemoryService,
|
MemoryService,
|
||||||
MetadataService,
|
MetadataService,
|
||||||
|
NotificationService,
|
||||||
PartnerService,
|
PartnerService,
|
||||||
PersonService,
|
PersonService,
|
||||||
SearchService,
|
SearchService,
|
||||||
|
|
|
@ -120,6 +120,7 @@ describe(JobService.name, () => {
|
||||||
[QueueName.FACIAL_RECOGNITION]: expectedJobStatus,
|
[QueueName.FACIAL_RECOGNITION]: expectedJobStatus,
|
||||||
[QueueName.SIDECAR]: expectedJobStatus,
|
[QueueName.SIDECAR]: expectedJobStatus,
|
||||||
[QueueName.LIBRARY]: expectedJobStatus,
|
[QueueName.LIBRARY]: expectedJobStatus,
|
||||||
|
[QueueName.NOTIFICATION]: expectedJobStatus,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -252,6 +253,7 @@ describe(JobService.name, () => {
|
||||||
[QueueName.MIGRATION]: { concurrency: 10 },
|
[QueueName.MIGRATION]: { concurrency: 10 },
|
||||||
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 },
|
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 },
|
||||||
[QueueName.VIDEO_CONVERSION]: { concurrency: 10 },
|
[QueueName.VIDEO_CONVERSION]: { concurrency: 10 },
|
||||||
|
[QueueName.NOTIFICATION]: { concurrency: 5 },
|
||||||
},
|
},
|
||||||
} as SystemConfig);
|
} as SystemConfig);
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { JobService } from 'src/services/job.service';
|
||||||
import { LibraryService } from 'src/services/library.service';
|
import { LibraryService } from 'src/services/library.service';
|
||||||
import { MediaService } from 'src/services/media.service';
|
import { MediaService } from 'src/services/media.service';
|
||||||
import { MetadataService } from 'src/services/metadata.service';
|
import { MetadataService } from 'src/services/metadata.service';
|
||||||
|
import { NotificationService } from 'src/services/notification.service';
|
||||||
import { PersonService } from 'src/services/person.service';
|
import { PersonService } from 'src/services/person.service';
|
||||||
import { SessionService } from 'src/services/session.service';
|
import { SessionService } from 'src/services/session.service';
|
||||||
import { SmartInfoService } from 'src/services/smart-info.service';
|
import { SmartInfoService } from 'src/services/smart-info.service';
|
||||||
|
@ -22,23 +23,25 @@ export class MicroservicesService {
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
private assetService: AssetService,
|
private assetService: AssetService,
|
||||||
private configService: SystemConfigService,
|
private configService: SystemConfigService,
|
||||||
|
private databaseService: DatabaseService,
|
||||||
private jobService: JobService,
|
private jobService: JobService,
|
||||||
private libraryService: LibraryService,
|
private libraryService: LibraryService,
|
||||||
private mediaService: MediaService,
|
private mediaService: MediaService,
|
||||||
private metadataService: MetadataService,
|
private metadataService: MetadataService,
|
||||||
|
private notificationService: NotificationService,
|
||||||
private personService: PersonService,
|
private personService: PersonService,
|
||||||
private smartInfoService: SmartInfoService,
|
private smartInfoService: SmartInfoService,
|
||||||
private sessionService: SessionService,
|
private sessionService: SessionService,
|
||||||
private storageTemplateService: StorageTemplateService,
|
private storageTemplateService: StorageTemplateService,
|
||||||
private storageService: StorageService,
|
private storageService: StorageService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private databaseService: DatabaseService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.databaseService.init();
|
await this.databaseService.init();
|
||||||
await this.configService.init();
|
await this.configService.init();
|
||||||
await this.libraryService.init();
|
await this.libraryService.init();
|
||||||
|
await this.notificationService.init();
|
||||||
await this.jobService.init({
|
await this.jobService.init({
|
||||||
[JobName.ASSET_DELETION]: (data) => this.assetService.handleAssetDeletion(data),
|
[JobName.ASSET_DELETION]: (data) => this.assetService.handleAssetDeletion(data),
|
||||||
[JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(),
|
[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_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data),
|
||||||
[JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data),
|
[JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data),
|
||||||
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
|
[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();
|
await this.metadataService.init();
|
||||||
|
|
98
server/src/services/notification.service.ts
Normal file
98
server/src/services/notification.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -186,6 +186,7 @@ describe(ServerInfoService.name, () => {
|
||||||
sidecar: true,
|
sidecar: true,
|
||||||
configFile: false,
|
configFile: false,
|
||||||
trash: true,
|
trash: true,
|
||||||
|
email: false,
|
||||||
});
|
});
|
||||||
expect(configMock.load).toHaveBeenCalled();
|
expect(configMock.load).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
|
@ -44,6 +44,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||||
[QueueName.MIGRATION]: { concurrency: 5 },
|
[QueueName.MIGRATION]: { concurrency: 5 },
|
||||||
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
|
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
|
||||||
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
|
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
|
||||||
|
[QueueName.NOTIFICATION]: { concurrency: 5 },
|
||||||
},
|
},
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
crf: 30,
|
crf: 30,
|
||||||
|
@ -153,6 +154,20 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||||
user: {
|
user: {
|
||||||
deleteDelay: 15,
|
deleteDelay: 15,
|
||||||
},
|
},
|
||||||
|
notifications: {
|
||||||
|
smtp: {
|
||||||
|
enabled: false,
|
||||||
|
from: '',
|
||||||
|
replyTo: '',
|
||||||
|
transport: {
|
||||||
|
host: '',
|
||||||
|
port: 587,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
ignoreCert: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(SystemConfigService.name, () => {
|
describe(SystemConfigService.name, () => {
|
||||||
|
|
|
@ -60,8 +60,13 @@ export class UserService {
|
||||||
return this.findOrFail(auth.user.id, {}).then(mapUser);
|
return this.findOrFail(auth.user.id, {}).then(mapUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
create(createUserDto: CreateUserDto): Promise<UserResponseDto> {
|
async create(dto: CreateUserDto): Promise<UserResponseDto> {
|
||||||
return this.userCore.createUser(createUserDto).then(mapUser);
|
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> {
|
async update(auth: AuthDto, dto: UpdateUserDto): Promise<UserResponseDto> {
|
||||||
|
|
10
server/test/repositories/notification.repository.mock.ts
Normal file
10
server/test/repositories/notification.repository.mock.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
};
|
|
@ -17,6 +17,7 @@
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"preserveWatchOutput": true,
|
"preserveWatchOutput": true,
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
|
"jsx": "react",
|
||||||
"types": ["vitest/globals"]
|
"types": ["vitest/globals"]
|
||||||
},
|
},
|
||||||
"exclude": ["dist", "node_modules", "upload"]
|
"exclude": ["dist", "node_modules", "upload"]
|
||||||
|
|
|
@ -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: "Immich Photo Server <noreply@immich.app>""
|
||||||
|
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>
|
|
@ -8,6 +8,7 @@
|
||||||
import PasswordField from '../shared-components/password-field.svelte';
|
import PasswordField from '../shared-components/password-field.svelte';
|
||||||
import Slider from '../elements/slider.svelte';
|
import Slider from '../elements/slider.svelte';
|
||||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||||
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
|
|
||||||
export let onClose: () => void;
|
export let onClose: () => void;
|
||||||
|
|
||||||
|
@ -19,6 +20,7 @@
|
||||||
let confirmPassword = '';
|
let confirmPassword = '';
|
||||||
let name = '';
|
let name = '';
|
||||||
let shouldChangePassword = true;
|
let shouldChangePassword = true;
|
||||||
|
let notify = true;
|
||||||
|
|
||||||
let canCreateUser = false;
|
let canCreateUser = false;
|
||||||
let quotaSize: number | undefined;
|
let quotaSize: number | undefined;
|
||||||
|
@ -54,6 +56,7 @@
|
||||||
shouldChangePassword,
|
shouldChangePassword,
|
||||||
name,
|
name,
|
||||||
quotaSizeInBytes,
|
quotaSizeInBytes,
|
||||||
|
notify,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -78,6 +81,13 @@
|
||||||
<input class="immich-form-input" id="email" bind:value={email} type="email" required />
|
<input class="immich-form-input" id="email" bind:value={email} type="email" required />
|
||||||
</div>
|
</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">
|
<div class="my-4 flex flex-col gap-2">
|
||||||
<label class="immich-form-label" for="password">Password</label>
|
<label class="immich-form-label" for="password">Password</label>
|
||||||
<PasswordField id="password" bind:password autocomplete="new-password" />
|
<PasswordField id="password" bind:password autocomplete="new-password" />
|
||||||
|
|
|
@ -16,6 +16,7 @@ export const featureFlags = writable<FeatureFlags>({
|
||||||
passwordLogin: true,
|
passwordLogin: true,
|
||||||
configFile: false,
|
configFile: false,
|
||||||
trash: true,
|
trash: true,
|
||||||
|
email: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ServerConfig = ServerConfigDto & { loaded: boolean };
|
export type ServerConfig = ServerConfigDto & { loaded: boolean };
|
||||||
|
|
|
@ -124,6 +124,7 @@ export const getJobName = (jobName: JobName) => {
|
||||||
[JobName.BackgroundTask]: 'Background Tasks',
|
[JobName.BackgroundTask]: 'Background Tasks',
|
||||||
[JobName.Search]: 'Search',
|
[JobName.Search]: 'Search',
|
||||||
[JobName.Library]: 'Library',
|
[JobName.Library]: 'Library',
|
||||||
|
[JobName.Notifications]: 'Notifications',
|
||||||
};
|
};
|
||||||
|
|
||||||
return names[jobName];
|
return names[jobName];
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte';
|
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 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 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 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 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';
|
import ThemeSettings from '$lib/components/admin-page/settings/theme/theme-settings.svelte';
|
||||||
|
@ -48,6 +49,7 @@
|
||||||
| typeof ImageSettings
|
| typeof ImageSettings
|
||||||
| typeof TrashSettings
|
| typeof TrashSettings
|
||||||
| typeof NewVersionCheckSettings
|
| typeof NewVersionCheckSettings
|
||||||
|
| typeof NotificationSettings
|
||||||
| typeof FFmpegSettings
|
| typeof FFmpegSettings
|
||||||
| typeof UserSettings;
|
| typeof UserSettings;
|
||||||
|
|
||||||
|
@ -116,6 +118,12 @@
|
||||||
subtitle: 'Manage map related features and setting',
|
subtitle: 'Manage map related features and setting',
|
||||||
key: 'location',
|
key: 'location',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
item: NotificationSettings,
|
||||||
|
title: 'Notification Settings',
|
||||||
|
subtitle: 'Manage notification settings, including email',
|
||||||
|
key: 'notifications',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
item: OAuthSettings,
|
item: OAuthSettings,
|
||||||
title: 'OAuth Authentication',
|
title: 'OAuth Authentication',
|
||||||
|
|
Loading…
Reference in a new issue