1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-28 14:41: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:
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 5880 additions and 366 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

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/SystemConfigSmtpDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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