diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 880e52442b..293cc11657 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -1,12 +1,17 @@ import { defaults, SystemConfig } from 'src/config'; +import { AlbumUserEntity } from 'src/entities/album-user.entity'; +import { UserMetadataKey } from 'src/entities/user-metadata.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IJobRepository } from 'src/interfaces/job.interface'; +import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { INotificationRepository } from 'src/interfaces/notification.interface'; +import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { NotificationService } from 'src/services/notification.service'; +import { albumStub } from 'test/fixtures/album.stub'; +import { assetStub } from 'test/fixtures/asset.stub'; +import { userStub } from 'test/fixtures/user.stub'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; @@ -54,23 +59,23 @@ const configs = { }; describe(NotificationService.name, () => { - let sut: NotificationService; - let systemMock: Mocked; - let notificationMock: Mocked; - let userMock: Mocked; + let albumMock: Mocked; + let assetMock: Mocked; let jobMock: Mocked; let loggerMock: Mocked; - let assetMock: Mocked; - let albumMock: Mocked; + let notificationMock: Mocked; + let sut: NotificationService; + let systemMock: Mocked; + let userMock: Mocked; beforeEach(() => { - systemMock = newSystemMetadataRepositoryMock(); - notificationMock = newNotificationRepositoryMock(); - userMock = newUserRepositoryMock(); + albumMock = newAlbumRepositoryMock(); + assetMock = newAssetRepositoryMock(); jobMock = newJobRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - assetMock = newAssetRepositoryMock(); - albumMock = newAlbumRepositoryMock(); + notificationMock = newNotificationRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); + userMock = newUserRepositoryMock(); sut = new NotificationService(systemMock, notificationMock, userMock, jobMock, loggerMock, assetMock, albumMock); }); @@ -114,4 +119,418 @@ describe(NotificationService.name, () => { expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); }); }); + + describe('onUserSignupEvent', () => { + it('skips when notify is false', async () => { + await sut.onUserSignupEvent({ id: '', notify: false }); + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + + it('should queue notify signup event if notify is true', async () => { + await sut.onUserSignupEvent({ id: '', notify: true }); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.NOTIFY_SIGNUP, + data: { id: '', tempPassword: undefined }, + }); + }); + }); + + describe('onAlbumUpdateEvent', () => { + it('should queue notify album update event', async () => { + await sut.onAlbumUpdateEvent({ id: '', updatedBy: '42' }); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.NOTIFY_ALBUM_UPDATE, + data: { id: '', senderId: '42' }, + }); + }); + }); + + describe('onAlbumInviteEvent', () => { + it('should queue notify album invite event', async () => { + await sut.onAlbumInviteEvent({ id: '', userId: '42' }); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.NOTIFY_ALBUM_INVITE, + data: { id: '', recipientId: '42' }, + }); + }); + }); + + describe('sendTestEmail', () => { + it('should throw error if user could not be found', async () => { + await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow('User not found'); + }); + + it('should throw error if smtp validation fails', async () => { + userMock.get.mockResolvedValue(userStub.admin); + notificationMock.verifySmtp.mockRejectedValue(''); + + await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow( + 'Failed to verify SMTP configuration', + ); + }); + + it('should send email to default domain', async () => { + userMock.get.mockResolvedValue(userStub.admin); + notificationMock.verifySmtp.mockResolvedValue(true); + notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + + await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); + expect(notificationMock.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'http://localhost:2283', displayName: userStub.admin.name }, + }); + expect(notificationMock.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: configs.smtpTransport.notifications.smtp.transport, + }), + ); + }); + + it('should send email to external domain', async () => { + userMock.get.mockResolvedValue(userStub.admin); + notificationMock.verifySmtp.mockResolvedValue(true); + notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + systemMock.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); + + await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); + expect(notificationMock.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name }, + }); + expect(notificationMock.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: configs.smtpTransport.notifications.smtp.transport, + }), + ); + }); + + it('should send email with replyTo', async () => { + userMock.get.mockResolvedValue(userStub.admin); + notificationMock.verifySmtp.mockResolvedValue(true); + notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + + await expect( + sut.sendTestEmail('', { ...configs.smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }), + ).resolves.not.toThrow(); + expect(notificationMock.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'http://localhost:2283', displayName: userStub.admin.name }, + }); + expect(notificationMock.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: configs.smtpTransport.notifications.smtp.transport, + replyTo: 'demo@immich.app', + }), + ); + }); + }); + + describe('handleUserSignup', () => { + it('should skip if user could not be found', async () => { + await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SKIPPED); + }); + + it('should be successful', async () => { + userMock.get.mockResolvedValue(userStub.admin); + systemMock.get.mockResolvedValue({ server: {} }); + notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + + await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SUCCESS); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.SEND_EMAIL, + data: expect.objectContaining({ subject: 'Welcome to Immich' }), + }); + }); + }); + + describe('handleAlbumInvite', () => { + it('should skip if album could not be found', async () => { + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); + expect(userMock.get).not.toHaveBeenCalled(); + }); + + it('should skip if recipient could not be found', async () => { + albumMock.getById.mockResolvedValue(albumStub.empty); + + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); + expect(assetMock.getById).not.toHaveBeenCalled(); + }); + + it('should skip if the recipient has email notifications disabled', async () => { + albumMock.getById.mockResolvedValue(albumStub.empty); + userMock.get.mockResolvedValue({ + ...userStub.user1, + metadata: [ + { + key: UserMetadataKey.PREFERENCES, + value: { emailNotifications: { enabled: false, albumInvite: true } }, + userId: userStub.user1.id, + user: userStub.user1, + }, + ], + }); + + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); + }); + + it('should skip if the recipient has email notifications for album invite disabled', async () => { + albumMock.getById.mockResolvedValue(albumStub.empty); + userMock.get.mockResolvedValue({ + ...userStub.user1, + metadata: [ + { + key: UserMetadataKey.PREFERENCES, + value: { emailNotifications: { enabled: true, albumInvite: false } }, + userId: userStub.user1.id, + user: userStub.user1, + }, + ], + }); + + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); + }); + + it('should send invite email', async () => { + albumMock.getById.mockResolvedValue(albumStub.empty); + userMock.get.mockResolvedValue({ + ...userStub.user1, + metadata: [ + { + key: UserMetadataKey.PREFERENCES, + value: { emailNotifications: { enabled: true, albumInvite: true } }, + userId: userStub.user1.id, + user: userStub.user1, + }, + ], + }); + systemMock.get.mockResolvedValue({ server: {} }); + notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.SEND_EMAIL, + data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album') }), + }); + }); + + it('should send invite email without album thumbnail if thumbnail asset does not exist', async () => { + albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + userMock.get.mockResolvedValue({ + ...userStub.user1, + metadata: [ + { + key: UserMetadataKey.PREFERENCES, + value: { emailNotifications: { enabled: true, albumInvite: true } }, + userId: userStub.user1.id, + user: userStub.user1, + }, + ], + }); + systemMock.get.mockResolvedValue({ server: {} }); + notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); + expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.SEND_EMAIL, + data: expect.objectContaining({ + subject: expect.stringContaining('You have been added to a shared album'), + imageAttachments: undefined, + }), + }); + }); + + it('should send invite email with album thumbnail as jpeg', async () => { + albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + userMock.get.mockResolvedValue({ + ...userStub.user1, + metadata: [ + { + key: UserMetadataKey.PREFERENCES, + value: { emailNotifications: { enabled: true, albumInvite: true } }, + userId: userStub.user1.id, + user: userStub.user1, + }, + ], + }); + systemMock.get.mockResolvedValue({ server: {} }); + notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + assetMock.getById.mockResolvedValue({ ...assetStub.image, thumbnailPath: 'path-to-thumb.jpg' }); + + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); + expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.SEND_EMAIL, + data: expect.objectContaining({ + subject: expect.stringContaining('You have been added to a shared album'), + imageAttachments: [{ filename: 'album-thumbnail.jpg', path: expect.anything(), cid: expect.anything() }], + }), + }); + }); + + it('should send invite email with album thumbnail and arbitrary extension', async () => { + albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + userMock.get.mockResolvedValue({ + ...userStub.user1, + metadata: [ + { + key: UserMetadataKey.PREFERENCES, + value: { emailNotifications: { enabled: true, albumInvite: true } }, + userId: userStub.user1.id, + user: userStub.user1, + }, + ], + }); + systemMock.get.mockResolvedValue({ server: {} }); + notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + assetMock.getById.mockResolvedValue(assetStub.image); + + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); + expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.SEND_EMAIL, + data: expect.objectContaining({ + subject: expect.stringContaining('You have been added to a shared album'), + imageAttachments: [{ filename: 'album-thumbnail.ext', path: expect.anything(), cid: expect.anything() }], + }), + }); + }); + }); + + describe('handleAlbumUpdate', () => { + it('should skip if album could not be found', async () => { + await expect(sut.handleAlbumUpdate({ id: '', senderId: '' })).resolves.toBe(JobStatus.SKIPPED); + expect(userMock.get).not.toHaveBeenCalled(); + }); + + it('should skip if owner could not be found', async () => { + albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); + + await expect(sut.handleAlbumUpdate({ id: '', senderId: '' })).resolves.toBe(JobStatus.SKIPPED); + expect(systemMock.get).not.toHaveBeenCalled(); + }); + + it('should filter out the sender', async () => { + albumMock.getById.mockResolvedValue({ + ...albumStub.emptyWithValidThumbnail, + albumUsers: [ + { user: { id: userStub.user1.id } } as AlbumUserEntity, + { user: { id: userStub.user2.id } } as AlbumUserEntity, + ], + }); + userMock.get.mockResolvedValue(userStub.user1); + notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + + await sut.handleAlbumUpdate({ id: '', senderId: userStub.user1.id }); + expect(userMock.get).not.toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(userMock.get).toHaveBeenCalledWith(userStub.user2.id, { withDeleted: false }); + expect(notificationMock.renderEmail).toHaveBeenCalledOnce(); + }); + + it('should skip recipient that could not be looked up', async () => { + albumMock.getById.mockResolvedValue({ + ...albumStub.emptyWithValidThumbnail, + albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], + }); + userMock.get.mockResolvedValueOnce(userStub.user1); + notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + + await sut.handleAlbumUpdate({ id: '', senderId: '' }); + expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(notificationMock.renderEmail).not.toHaveBeenCalled(); + }); + + it('should skip recipient with disabled email notifications', async () => { + albumMock.getById.mockResolvedValue({ + ...albumStub.emptyWithValidThumbnail, + albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], + }); + userMock.get.mockResolvedValue({ + ...userStub.user1, + metadata: [ + { + key: UserMetadataKey.PREFERENCES, + value: { emailNotifications: { enabled: false, albumUpdate: true } }, + user: userStub.user1, + userId: userStub.user1.id, + }, + ], + }); + notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + + await sut.handleAlbumUpdate({ id: '', senderId: '' }); + expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(notificationMock.renderEmail).not.toHaveBeenCalled(); + }); + + it('should skip recipient with disabled email notifications for the album update event', async () => { + albumMock.getById.mockResolvedValue({ + ...albumStub.emptyWithValidThumbnail, + albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], + }); + userMock.get.mockResolvedValue({ + ...userStub.user1, + metadata: [ + { + key: UserMetadataKey.PREFERENCES, + value: { emailNotifications: { enabled: true, albumUpdate: false } }, + user: userStub.user1, + userId: userStub.user1.id, + }, + ], + }); + notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + + await sut.handleAlbumUpdate({ id: '', senderId: '' }); + expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(notificationMock.renderEmail).not.toHaveBeenCalled(); + }); + + it('should send email', async () => { + albumMock.getById.mockResolvedValue({ + ...albumStub.emptyWithValidThumbnail, + albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], + }); + userMock.get.mockResolvedValue(userStub.user1); + notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); + + await sut.handleAlbumUpdate({ id: '', senderId: '' }); + expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); + expect(notificationMock.renderEmail).toHaveBeenCalled(); + expect(jobMock.queue).toHaveBeenCalled(); + }); + }); + + describe('handleSendEmail', () => { + it('should skip if smtp notifications are disabled', async () => { + systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: false } } }); + await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SKIPPED); + }); + + it('should fail if email could not be sent', async () => { + systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true } } }); + await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.FAILED); + }); + + it('should send mail successfully', async () => { + systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app' } } }); + notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' }); + + await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS); + expect(notificationMock.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'test@immich.app' })); + }); + + it('should send mail with replyTo successfully', async () => { + systemMock.get.mockResolvedValue({ + notifications: { smtp: { enabled: true, from: 'test@immich.app', replyTo: 'demo@immich.app' } }, + }); + notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' }); + + await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS); + expect(notificationMock.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'demo@immich.app' })); + }); + }); }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index dd9a1498d4..c5f9a4f9f7 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -26,6 +26,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailImageAttachment, EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { getFilenameExtension } from 'src/utils/file'; import { getPreferences } from 'src/utils/preferences'; @Injectable() @@ -274,7 +275,7 @@ export class NotificationService implements OnEvents { } return { - filename: 'album-thumbnail.jpg', + filename: `album-thumbnail${getFilenameExtension(albumThumbnail.thumbnailPath)}`, path: albumThumbnail.thumbnailPath, cid: 'album-thumbnail', }; diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 578aa66246..53a4d571dc 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -11,6 +11,10 @@ export function getFileNameWithoutExtension(path: string): string { return basename(path, extname(path)); } +export function getFilenameExtension(path: string): string { + return extname(path); +} + export function getLivePhotoMotionFilename(stillName: string, motionName: string) { return getFileNameWithoutExtension(stillName) + extname(motionName); } diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index f6047d522e..4105b01978 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -138,9 +138,9 @@ export const albumStub = { isActivityEnabled: true, order: AssetOrder.DESC, }), - emptyWithInvalidThumbnail: Object.freeze({ + emptyWithValidThumbnail: Object.freeze({ id: 'album-5', - albumName: 'Empty album with invalid thumbnail', + albumName: 'Empty album with valid thumbnail', description: '', ownerId: authStub.admin.user.id, owner: userStub.admin, @@ -155,7 +155,7 @@ export const albumStub = { isActivityEnabled: true, order: AssetOrder.DESC, }), - emptyWithValidThumbnail: Object.freeze({ + emptyWithInvalidThumbnail: Object.freeze({ id: 'album-5', albumName: 'Empty album with invalid thumbnail', description: '',