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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
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,
|
||||
configFile: false,
|
||||
trash: true,
|
||||
email: false,
|
||||
});
|
||||
expect(configMock.load).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user