refactor: email repository (#17746)

This commit is contained in:
Jason Rasmussen 2025-04-21 12:53:37 -04:00 committed by GitHub
parent 488dc4efbd
commit 56a4aa9ffe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 74 additions and 78 deletions

View File

@ -4,7 +4,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto'; import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { EmailTemplate } from 'src/repositories/notification.repository'; import { EmailTemplate } from 'src/repositories/email.repository';
import { NotificationService } from 'src/services/notification.service'; import { NotificationService } from 'src/services/notification.service';
@ApiTags('Notifications (Admin)') @ApiTags('Notifications (Admin)')

View File

@ -2,7 +2,7 @@ import { Img, Link, Section, Text } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component'; import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout'; import ImmichLayout from 'src/emails/components/immich.layout';
import { AlbumInviteEmailProps } from 'src/repositories/notification.repository'; import { AlbumInviteEmailProps } from 'src/repositories/email.repository';
import { replaceTemplateTags } from 'src/utils/replace-template-tags'; import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const AlbumInviteEmail = ({ export const AlbumInviteEmail = ({

View File

@ -2,7 +2,7 @@ import { Img, Link, Section, Text } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component'; import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout'; import ImmichLayout from 'src/emails/components/immich.layout';
import { AlbumUpdateEmailProps } from 'src/repositories/notification.repository'; import { AlbumUpdateEmailProps } from 'src/repositories/email.repository';
import { replaceTemplateTags } from 'src/utils/replace-template-tags'; import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const AlbumUpdateEmail = ({ export const AlbumUpdateEmail = ({

View File

@ -1,7 +1,7 @@
import { Link, Row, Text } from '@react-email/components'; import { Link, Row, Text } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import ImmichLayout from 'src/emails/components/immich.layout'; import ImmichLayout from 'src/emails/components/immich.layout';
import { TestEmailProps } from 'src/repositories/notification.repository'; import { TestEmailProps } from 'src/repositories/email.repository';
export const TestEmail = ({ baseUrl, displayName }: TestEmailProps) => ( export const TestEmail = ({ baseUrl, displayName }: TestEmailProps) => (
<ImmichLayout preview="This is a test email from Immich."> <ImmichLayout preview="This is a test email from Immich.">

View File

@ -2,7 +2,7 @@ import { Link, Section, Text } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component'; import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout'; import ImmichLayout from 'src/emails/components/immich.layout';
import { WelcomeEmailProps } from 'src/repositories/notification.repository'; import { WelcomeEmailProps } from 'src/repositories/email.repository';
import { replaceTemplateTags } from 'src/utils/replace-template-tags'; import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const WelcomeEmail = ({ baseUrl, displayName, username, password, customTemplate }: WelcomeEmailProps) => { export const WelcomeEmail = ({ baseUrl, displayName, username, password, customTemplate }: WelcomeEmailProps) => {

View File

@ -1,13 +1,13 @@
import { EmailRenderRequest, EmailRepository, EmailTemplate } from 'src/repositories/email.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { EmailRenderRequest, EmailTemplate, NotificationRepository } from 'src/repositories/notification.repository';
import { automock } from 'test/utils'; import { automock } from 'test/utils';
describe(NotificationRepository.name, () => { describe(EmailRepository.name, () => {
let sut: NotificationRepository; let sut: EmailRepository;
beforeEach(() => { beforeEach(() => {
// eslint-disable-next-line no-sparse-arrays // eslint-disable-next-line no-sparse-arrays
sut = new NotificationRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false })); sut = new EmailRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }));
}); });
describe('renderEmail', () => { describe('renderEmail', () => {

View File

@ -98,9 +98,9 @@ export type SendEmailResponse = {
}; };
@Injectable() @Injectable()
export class NotificationRepository { export class EmailRepository {
constructor(private logger: LoggingRepository) { constructor(private logger: LoggingRepository) {
this.logger.setContext(NotificationRepository.name); this.logger.setContext(EmailRepository.name);
} }
verifySmtp(options: SmtpOptions): Promise<true> { verifySmtp(options: SmtpOptions): Promise<true> {

View File

@ -11,6 +11,7 @@ import { CronRepository } from 'src/repositories/cron.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository'; import { DatabaseRepository } from 'src/repositories/database.repository';
import { DownloadRepository } from 'src/repositories/download.repository'; import { DownloadRepository } from 'src/repositories/download.repository';
import { EmailRepository } from 'src/repositories/email.repository';
import { EventRepository } from 'src/repositories/event.repository'; import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository'; import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository'; import { LibraryRepository } from 'src/repositories/library.repository';
@ -21,7 +22,6 @@ import { MediaRepository } from 'src/repositories/media.repository';
import { MemoryRepository } from 'src/repositories/memory.repository'; import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository'; import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OAuthRepository } from 'src/repositories/oauth.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';
@ -65,7 +65,7 @@ export const repositories = [
MemoryRepository, MemoryRepository,
MetadataRepository, MetadataRepository,
MoveRepository, MoveRepository,
NotificationRepository, EmailRepository,
OAuthRepository, OAuthRepository,
PartnerRepository, PartnerRepository,
PersonRepository, PersonRepository,

View File

@ -18,6 +18,7 @@ import { CronRepository } from 'src/repositories/cron.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository'; import { DatabaseRepository } from 'src/repositories/database.repository';
import { DownloadRepository } from 'src/repositories/download.repository'; import { DownloadRepository } from 'src/repositories/download.repository';
import { EmailRepository } from 'src/repositories/email.repository';
import { EventRepository } from 'src/repositories/event.repository'; import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository'; import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository'; import { LibraryRepository } from 'src/repositories/library.repository';
@ -28,7 +29,6 @@ import { MediaRepository } from 'src/repositories/media.repository';
import { MemoryRepository } from 'src/repositories/memory.repository'; import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository'; import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OAuthRepository } from 'src/repositories/oauth.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';
@ -70,6 +70,7 @@ export class BaseService {
protected cryptoRepository: CryptoRepository, protected cryptoRepository: CryptoRepository,
protected databaseRepository: DatabaseRepository, protected databaseRepository: DatabaseRepository,
protected downloadRepository: DownloadRepository, protected downloadRepository: DownloadRepository,
protected emailRepository: EmailRepository,
protected eventRepository: EventRepository, protected eventRepository: EventRepository,
protected jobRepository: JobRepository, protected jobRepository: JobRepository,
protected libraryRepository: LibraryRepository, protected libraryRepository: LibraryRepository,
@ -79,7 +80,6 @@ export class BaseService {
protected memoryRepository: MemoryRepository, protected memoryRepository: MemoryRepository,
protected metadataRepository: MetadataRepository, protected metadataRepository: MetadataRepository,
protected moveRepository: MoveRepository, protected moveRepository: MoveRepository,
protected notificationRepository: NotificationRepository,
protected oauthRepository: OAuthRepository, protected oauthRepository: OAuthRepository,
protected partnerRepository: PartnerRepository, protected partnerRepository: PartnerRepository,
protected personRepository: PersonRepository, protected personRepository: PersonRepository,

View File

@ -3,7 +3,7 @@ import { defaults, SystemConfig } from 'src/config';
import { AlbumUser } from 'src/database'; import { AlbumUser } from 'src/database';
import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
import { EmailTemplate } from 'src/repositories/notification.repository'; import { EmailTemplate } from 'src/repositories/email.repository';
import { NotificationService } from 'src/services/notification.service'; import { NotificationService } from 'src/services/notification.service';
import { INotifyAlbumUpdateJob } from 'src/types'; import { INotifyAlbumUpdateJob } from 'src/types';
import { albumStub } from 'test/fixtures/album.stub'; import { albumStub } from 'test/fixtures/album.stub';
@ -74,18 +74,18 @@ describe(NotificationService.name, () => {
const oldConfig = configs.smtpDisabled; const oldConfig = configs.smtpDisabled;
const newConfig = configs.smtpEnabled; const newConfig = configs.smtpEnabled;
mocks.notification.verifySmtp.mockResolvedValue(true); mocks.email.verifySmtp.mockResolvedValue(true);
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
expect(mocks.notification.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); expect(mocks.email.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport);
}); });
it('validates smtp config when transport changes', async () => { it('validates smtp config when transport changes', async () => {
const oldConfig = configs.smtpEnabled; const oldConfig = configs.smtpEnabled;
const newConfig = configs.smtpTransport; const newConfig = configs.smtpTransport;
mocks.notification.verifySmtp.mockResolvedValue(true); mocks.email.verifySmtp.mockResolvedValue(true);
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
expect(mocks.notification.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); expect(mocks.email.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport);
}); });
it('skips smtp validation when there are no changes', async () => { it('skips smtp validation when there are no changes', async () => {
@ -93,7 +93,7 @@ describe(NotificationService.name, () => {
const newConfig = { ...configs.smtpEnabled }; const newConfig = { ...configs.smtpEnabled };
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); expect(mocks.email.verifySmtp).not.toHaveBeenCalled();
}); });
it('skips smtp validation with DTO when there are no changes', async () => { it('skips smtp validation with DTO when there are no changes', async () => {
@ -101,7 +101,7 @@ describe(NotificationService.name, () => {
const newConfig = plainToInstance(SystemConfigDto, configs.smtpEnabled); const newConfig = plainToInstance(SystemConfigDto, configs.smtpEnabled);
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); expect(mocks.email.verifySmtp).not.toHaveBeenCalled();
}); });
it('skips smtp validation when smtp is disabled', async () => { it('skips smtp validation when smtp is disabled', async () => {
@ -109,14 +109,14 @@ describe(NotificationService.name, () => {
const newConfig = { ...configs.smtpDisabled }; const newConfig = { ...configs.smtpDisabled };
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
expect(mocks.notification.verifySmtp).not.toHaveBeenCalled(); expect(mocks.email.verifySmtp).not.toHaveBeenCalled();
}); });
it('should fail if smtp configuration is invalid', async () => { it('should fail if smtp configuration is invalid', async () => {
const oldConfig = configs.smtpDisabled; const oldConfig = configs.smtpDisabled;
const newConfig = configs.smtpEnabled; const newConfig = configs.smtpEnabled;
mocks.notification.verifySmtp.mockRejectedValue(new Error('Failed validating smtp')); mocks.email.verifySmtp.mockRejectedValue(new Error('Failed validating smtp'));
await expect(sut.onConfigValidate({ oldConfig, newConfig })).rejects.toBeInstanceOf(Error); await expect(sut.onConfigValidate({ oldConfig, newConfig })).rejects.toBeInstanceOf(Error);
}); });
}); });
@ -248,7 +248,7 @@ describe(NotificationService.name, () => {
it('should throw error if smtp validation fails', async () => { it('should throw error if smtp validation fails', async () => {
mocks.user.get.mockResolvedValue(userStub.admin); mocks.user.get.mockResolvedValue(userStub.admin);
mocks.notification.verifySmtp.mockRejectedValue(''); mocks.email.verifySmtp.mockRejectedValue('');
await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow( await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow(
'Failed to verify SMTP configuration', 'Failed to verify SMTP configuration',
@ -257,16 +257,16 @@ describe(NotificationService.name, () => {
it('should send email to default domain', async () => { it('should send email to default domain', async () => {
mocks.user.get.mockResolvedValue(userStub.admin); mocks.user.get.mockResolvedValue(userStub.admin);
mocks.notification.verifySmtp.mockResolvedValue(true); mocks.email.verifySmtp.mockResolvedValue(true);
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.notification.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow();
expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ expect(mocks.email.renderEmail).toHaveBeenCalledWith({
template: EmailTemplate.TEST_EMAIL, template: EmailTemplate.TEST_EMAIL,
data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name },
}); });
expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect(mocks.email.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
subject: 'Test email from Immich', subject: 'Test email from Immich',
smtp: configs.smtpTransport.notifications.smtp.transport, smtp: configs.smtpTransport.notifications.smtp.transport,
@ -276,17 +276,17 @@ describe(NotificationService.name, () => {
it('should send email to external domain', async () => { it('should send email to external domain', async () => {
mocks.user.get.mockResolvedValue(userStub.admin); mocks.user.get.mockResolvedValue(userStub.admin);
mocks.notification.verifySmtp.mockResolvedValue(true); mocks.email.verifySmtp.mockResolvedValue(true);
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } });
mocks.notification.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow();
expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ expect(mocks.email.renderEmail).toHaveBeenCalledWith({
template: EmailTemplate.TEST_EMAIL, template: EmailTemplate.TEST_EMAIL,
data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name }, data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name },
}); });
expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect(mocks.email.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
subject: 'Test email from Immich', subject: 'Test email from Immich',
smtp: configs.smtpTransport.notifications.smtp.transport, smtp: configs.smtpTransport.notifications.smtp.transport,
@ -296,18 +296,18 @@ describe(NotificationService.name, () => {
it('should send email with replyTo', async () => { it('should send email with replyTo', async () => {
mocks.user.get.mockResolvedValue(userStub.admin); mocks.user.get.mockResolvedValue(userStub.admin);
mocks.notification.verifySmtp.mockResolvedValue(true); mocks.email.verifySmtp.mockResolvedValue(true);
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.notification.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' });
await expect( await expect(
sut.sendTestEmail('', { ...configs.smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }), sut.sendTestEmail('', { ...configs.smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }),
).resolves.not.toThrow(); ).resolves.not.toThrow();
expect(mocks.notification.renderEmail).toHaveBeenCalledWith({ expect(mocks.email.renderEmail).toHaveBeenCalledWith({
template: EmailTemplate.TEST_EMAIL, template: EmailTemplate.TEST_EMAIL,
data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name },
}); });
expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect(mocks.email.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
subject: 'Test email from Immich', subject: 'Test email from Immich',
smtp: configs.smtpTransport.notifications.smtp.transport, smtp: configs.smtpTransport.notifications.smtp.transport,
@ -325,7 +325,7 @@ describe(NotificationService.name, () => {
it('should be successful', async () => { it('should be successful', async () => {
mocks.user.get.mockResolvedValue(userStub.admin); mocks.user.get.mockResolvedValue(userStub.admin);
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.systemMetadata.get.mockResolvedValue({ server: {} });
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
@ -390,7 +390,7 @@ describe(NotificationService.name, () => {
], ],
}); });
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.systemMetadata.get.mockResolvedValue({ server: {} });
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
@ -411,7 +411,7 @@ describe(NotificationService.name, () => {
], ],
}); });
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.systemMetadata.get.mockResolvedValue({ server: {} });
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
@ -440,7 +440,7 @@ describe(NotificationService.name, () => {
], ],
}); });
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.systemMetadata.get.mockResolvedValue({ server: {} });
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([ mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([
{ id: '1', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' }, { id: '1', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' },
]); ]);
@ -471,7 +471,7 @@ describe(NotificationService.name, () => {
], ],
}); });
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.systemMetadata.get.mockResolvedValue({ server: {} });
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetStub.image.files[2]]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetStub.image.files[2]]);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
@ -508,12 +508,12 @@ describe(NotificationService.name, () => {
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser], albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser],
}); });
mocks.user.get.mockResolvedValueOnce(userStub.user1); mocks.user.get.mockResolvedValueOnce(userStub.user1);
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); expect(mocks.email.renderEmail).not.toHaveBeenCalled();
}); });
it('should skip recipient with disabled email notifications', async () => { it('should skip recipient with disabled email notifications', async () => {
@ -530,12 +530,12 @@ describe(NotificationService.name, () => {
}, },
], ],
}); });
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); expect(mocks.email.renderEmail).not.toHaveBeenCalled();
}); });
it('should skip recipient with disabled email notifications for the album update event', async () => { it('should skip recipient with disabled email notifications for the album update event', async () => {
@ -552,12 +552,12 @@ describe(NotificationService.name, () => {
}, },
], ],
}); });
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(mocks.notification.renderEmail).not.toHaveBeenCalled(); expect(mocks.email.renderEmail).not.toHaveBeenCalled();
}); });
it('should send email', async () => { it('should send email', async () => {
@ -566,12 +566,12 @@ describe(NotificationService.name, () => {
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser], albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser],
}); });
mocks.user.get.mockResolvedValue(userStub.user1); mocks.user.get.mockResolvedValue(userStub.user1);
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(mocks.notification.renderEmail).toHaveBeenCalled(); expect(mocks.email.renderEmail).toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalled();
}); });
@ -599,24 +599,20 @@ describe(NotificationService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ mocks.systemMetadata.get.mockResolvedValue({
notifications: { smtp: { enabled: true, from: 'test@immich.app' } }, notifications: { smtp: { enabled: true, from: 'test@immich.app' } },
}); });
mocks.notification.sendEmail.mockResolvedValue({ messageId: '', response: '' }); mocks.email.sendEmail.mockResolvedValue({ messageId: '', response: '' });
await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect(mocks.email.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'test@immich.app' }));
expect.objectContaining({ replyTo: 'test@immich.app' }),
);
}); });
it('should send mail with replyTo successfully', async () => { it('should send mail with replyTo successfully', async () => {
mocks.systemMetadata.get.mockResolvedValue({ mocks.systemMetadata.get.mockResolvedValue({
notifications: { smtp: { enabled: true, from: 'test@immich.app', replyTo: 'demo@immich.app' } }, notifications: { smtp: { enabled: true, from: 'test@immich.app', replyTo: 'demo@immich.app' } },
}); });
mocks.notification.sendEmail.mockResolvedValue({ messageId: '', response: '' }); mocks.email.sendEmail.mockResolvedValue({ messageId: '', response: '' });
await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.notification.sendEmail).toHaveBeenCalledWith( expect(mocks.email.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'demo@immich.app' }));
expect.objectContaining({ replyTo: 'demo@immich.app' }),
);
}); });
}); });
}); });

View File

@ -2,8 +2,8 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum'; import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum';
import { EmailTemplate } from 'src/repositories/email.repository';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { EmailTemplate } from 'src/repositories/notification.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { EmailImageAttachment, IEntityJob, INotifyAlbumUpdateJob, JobItem, JobOf } from 'src/types'; import { EmailImageAttachment, IEntityJob, INotifyAlbumUpdateJob, JobItem, JobOf } from 'src/types';
import { getFilenameExtension } from 'src/utils/file'; import { getFilenameExtension } from 'src/utils/file';
@ -28,7 +28,7 @@ export class NotificationService extends BaseService {
newConfig.notifications.smtp.enabled && newConfig.notifications.smtp.enabled &&
!isEqualObject(oldConfig.notifications.smtp, newConfig.notifications.smtp) !isEqualObject(oldConfig.notifications.smtp, newConfig.notifications.smtp)
) { ) {
await this.notificationRepository.verifySmtp(newConfig.notifications.smtp.transport); await this.emailRepository.verifySmtp(newConfig.notifications.smtp.transport);
} }
} catch (error: Error | any) { } catch (error: Error | any) {
this.logger.error(`Failed to validate SMTP configuration: ${error}`, error?.stack); this.logger.error(`Failed to validate SMTP configuration: ${error}`, error?.stack);
@ -138,13 +138,13 @@ export class NotificationService extends BaseService {
} }
try { try {
await this.notificationRepository.verifySmtp(dto.transport); await this.emailRepository.verifySmtp(dto.transport);
} catch (error) { } catch (error) {
throw new BadRequestException('Failed to verify SMTP configuration', { cause: error }); throw new BadRequestException('Failed to verify SMTP configuration', { cause: error });
} }
const { server } = await this.getConfig({ withCache: false }); const { server } = await this.getConfig({ withCache: false });
const { html, text } = await this.notificationRepository.renderEmail({ const { html, text } = await this.emailRepository.renderEmail({
template: EmailTemplate.TEST_EMAIL, template: EmailTemplate.TEST_EMAIL,
data: { data: {
baseUrl: getExternalDomain(server), baseUrl: getExternalDomain(server),
@ -152,7 +152,7 @@ export class NotificationService extends BaseService {
}, },
customTemplate: tempTemplate!, customTemplate: tempTemplate!,
}); });
const { messageId } = await this.notificationRepository.sendEmail({ const { messageId } = await this.emailRepository.sendEmail({
to: user.email, to: user.email,
subject: 'Test email from Immich', subject: 'Test email from Immich',
html, html,
@ -172,7 +172,7 @@ export class NotificationService extends BaseService {
switch (name) { switch (name) {
case EmailTemplate.WELCOME: { case EmailTemplate.WELCOME: {
const { html: _welcomeHtml } = await this.notificationRepository.renderEmail({ const { html: _welcomeHtml } = await this.emailRepository.renderEmail({
template: EmailTemplate.WELCOME, template: EmailTemplate.WELCOME,
data: { data: {
baseUrl: getExternalDomain(server), baseUrl: getExternalDomain(server),
@ -187,7 +187,7 @@ export class NotificationService extends BaseService {
break; break;
} }
case EmailTemplate.ALBUM_UPDATE: { case EmailTemplate.ALBUM_UPDATE: {
const { html: _updateAlbumHtml } = await this.notificationRepository.renderEmail({ const { html: _updateAlbumHtml } = await this.emailRepository.renderEmail({
template: EmailTemplate.ALBUM_UPDATE, template: EmailTemplate.ALBUM_UPDATE,
data: { data: {
baseUrl: getExternalDomain(server), baseUrl: getExternalDomain(server),
@ -203,7 +203,7 @@ export class NotificationService extends BaseService {
} }
case EmailTemplate.ALBUM_INVITE: { case EmailTemplate.ALBUM_INVITE: {
const { html } = await this.notificationRepository.renderEmail({ const { html } = await this.emailRepository.renderEmail({
template: EmailTemplate.ALBUM_INVITE, template: EmailTemplate.ALBUM_INVITE,
data: { data: {
baseUrl: getExternalDomain(server), baseUrl: getExternalDomain(server),
@ -235,7 +235,7 @@ export class NotificationService extends BaseService {
} }
const { server, templates } = await this.getConfig({ withCache: true }); const { server, templates } = await this.getConfig({ withCache: true });
const { html, text } = await this.notificationRepository.renderEmail({ const { html, text } = await this.emailRepository.renderEmail({
template: EmailTemplate.WELCOME, template: EmailTemplate.WELCOME,
data: { data: {
baseUrl: getExternalDomain(server), baseUrl: getExternalDomain(server),
@ -280,7 +280,7 @@ export class NotificationService extends BaseService {
const attachment = await this.getAlbumThumbnailAttachment(album); const attachment = await this.getAlbumThumbnailAttachment(album);
const { server, templates } = await this.getConfig({ withCache: false }); const { server, templates } = await this.getConfig({ withCache: false });
const { html, text } = await this.notificationRepository.renderEmail({ const { html, text } = await this.emailRepository.renderEmail({
template: EmailTemplate.ALBUM_INVITE, template: EmailTemplate.ALBUM_INVITE,
data: { data: {
baseUrl: getExternalDomain(server), baseUrl: getExternalDomain(server),
@ -339,7 +339,7 @@ export class NotificationService extends BaseService {
continue; continue;
} }
const { html, text } = await this.notificationRepository.renderEmail({ const { html, text } = await this.emailRepository.renderEmail({
template: EmailTemplate.ALBUM_UPDATE, template: EmailTemplate.ALBUM_UPDATE,
data: { data: {
baseUrl: getExternalDomain(server), baseUrl: getExternalDomain(server),
@ -374,7 +374,7 @@ export class NotificationService extends BaseService {
} }
const { to, subject, html, text: plain } = data; const { to, subject, html, text: plain } = data;
const response = await this.notificationRepository.sendEmail({ const response = await this.emailRepository.sendEmail({
to, to,
subject, subject,
html, html,

View File

@ -284,6 +284,7 @@ export const asDeps = (repositories: ServiceOverrides) => {
repositories.crypto || getRepositoryMock('crypto'), repositories.crypto || getRepositoryMock('crypto'),
repositories.database || getRepositoryMock('database'), repositories.database || getRepositoryMock('database'),
repositories.downloadRepository, repositories.downloadRepository,
repositories.email,
repositories.event, repositories.event,
repositories.job || getRepositoryMock('job'), repositories.job || getRepositoryMock('job'),
repositories.library, repositories.library,
@ -293,7 +294,6 @@ export const asDeps = (repositories: ServiceOverrides) => {
repositories.memory || getRepositoryMock('memory'), repositories.memory || getRepositoryMock('memory'),
repositories.metadata, repositories.metadata,
repositories.move, repositories.move,
repositories.notification,
repositories.oauth, repositories.oauth,
repositories.partner || getRepositoryMock('partner'), repositories.partner || getRepositoryMock('partner'),
repositories.person || getRepositoryMock('person'), repositories.person || getRepositoryMock('person'),

View File

@ -18,6 +18,7 @@ import { CronRepository } from 'src/repositories/cron.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository'; import { DatabaseRepository } from 'src/repositories/database.repository';
import { DownloadRepository } from 'src/repositories/download.repository'; import { DownloadRepository } from 'src/repositories/download.repository';
import { EmailRepository } from 'src/repositories/email.repository';
import { EventRepository } from 'src/repositories/event.repository'; import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository'; import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository'; import { LibraryRepository } from 'src/repositories/library.repository';
@ -28,7 +29,6 @@ import { MediaRepository } from 'src/repositories/media.repository';
import { MemoryRepository } from 'src/repositories/memory.repository'; import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository'; import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OAuthRepository } from 'src/repositories/oauth.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';
@ -124,6 +124,7 @@ export type ServiceOverrides = {
crypto: CryptoRepository; crypto: CryptoRepository;
database: DatabaseRepository; database: DatabaseRepository;
downloadRepository: DownloadRepository; downloadRepository: DownloadRepository;
email: EmailRepository;
event: EventRepository; event: EventRepository;
job: JobRepository; job: JobRepository;
library: LibraryRepository; library: LibraryRepository;
@ -134,7 +135,6 @@ export type ServiceOverrides = {
memory: MemoryRepository; memory: MemoryRepository;
metadata: MetadataRepository; metadata: MetadataRepository;
move: MoveRepository; move: MoveRepository;
notification: NotificationRepository;
oauth: OAuthRepository; oauth: OAuthRepository;
partner: PartnerRepository; partner: PartnerRepository;
person: PersonRepository; person: PersonRepository;
@ -190,6 +190,7 @@ export const newTestService = <T extends BaseService>(
config: newConfigRepositoryMock(), config: newConfigRepositoryMock(),
database: newDatabaseRepositoryMock(), database: newDatabaseRepositoryMock(),
downloadRepository: automock(DownloadRepository, { strict: false }), downloadRepository: automock(DownloadRepository, { strict: false }),
email: automock(EmailRepository, { args: [loggerMock] }),
// eslint-disable-next-line no-sparse-arrays // eslint-disable-next-line no-sparse-arrays
event: automock(EventRepository, { args: [, , loggerMock], strict: false }), event: automock(EventRepository, { args: [, , loggerMock], strict: false }),
job: newJobRepositoryMock(), job: newJobRepositoryMock(),
@ -201,7 +202,6 @@ export const newTestService = <T extends BaseService>(
memory: automock(MemoryRepository), memory: automock(MemoryRepository),
metadata: newMetadataRepositoryMock(), metadata: newMetadataRepositoryMock(),
move: automock(MoveRepository, { strict: false }), move: automock(MoveRepository, { strict: false }),
notification: automock(NotificationRepository, { args: [loggerMock] }),
oauth: automock(OAuthRepository, { args: [loggerMock] }), oauth: automock(OAuthRepository, { args: [loggerMock] }),
partner: automock(PartnerRepository, { strict: false }), partner: automock(PartnerRepository, { strict: false }),
person: newPersonRepositoryMock(), person: newPersonRepositoryMock(),
@ -240,6 +240,7 @@ export const newTestService = <T extends BaseService>(
overrides.crypto || (mocks.crypto as As<CryptoRepository>), overrides.crypto || (mocks.crypto as As<CryptoRepository>),
overrides.database || (mocks.database as As<DatabaseRepository>), overrides.database || (mocks.database as As<DatabaseRepository>),
overrides.downloadRepository || (mocks.downloadRepository as As<DownloadRepository>), overrides.downloadRepository || (mocks.downloadRepository as As<DownloadRepository>),
overrides.email || (mocks.email as As<EmailRepository>),
overrides.event || (mocks.event as As<EventRepository>), overrides.event || (mocks.event as As<EventRepository>),
overrides.job || (mocks.job as As<JobRepository>), overrides.job || (mocks.job as As<JobRepository>),
overrides.library || (mocks.library as As<LibraryRepository>), overrides.library || (mocks.library as As<LibraryRepository>),
@ -249,7 +250,6 @@ export const newTestService = <T extends BaseService>(
overrides.memory || (mocks.memory as As<MemoryRepository>), overrides.memory || (mocks.memory as As<MemoryRepository>),
overrides.metadata || (mocks.metadata as As<MetadataRepository>), overrides.metadata || (mocks.metadata as As<MetadataRepository>),
overrides.move || (mocks.move as As<MoveRepository>), overrides.move || (mocks.move as As<MoveRepository>),
overrides.notification || (mocks.notification as As<NotificationRepository>),
overrides.oauth || (mocks.oauth as As<OAuthRepository>), overrides.oauth || (mocks.oauth as As<OAuthRepository>),
overrides.partner || (mocks.partner as As<PartnerRepository>), overrides.partner || (mocks.partner as As<PartnerRepository>),
overrides.person || (mocks.person as As<PersonRepository>), overrides.person || (mocks.person as As<PersonRepository>),