From 1f9158c545f6fc93f6a3f342552a33f1fd53d7e1 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 28 May 2024 09:16:46 +0700 Subject: [PATCH] feat(server): album's email notification (#9439) * feat(server): album's email notification * same size button * skeleton for album invite and album update event * album invite content * album update * fix(server): smtp certificate validation (#9506) * album update content * send mail * album invite with thumbnail * pr feedback * styling * Send email to update album event * better naming * add tests * Update album-invite.email.tsx Co-authored-by: bo0tzz * Update album-update.email.tsx Co-authored-by: bo0tzz * fix: unit tests * typo * Update server/src/services/notification.service.ts Co-authored-by: Jason Rasmussen * PR feedback * Update server/src/emails/album-update.email.tsx Co-authored-by: Zack Pollard --------- Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Co-authored-by: bo0tzz Co-authored-by: Jason Rasmussen Co-authored-by: Zack Pollard --- docs/static/img/ios-app-store-badge.png | Bin 1797 -> 15634 bytes server/src/emails/album-invite.email.tsx | 174 ++++++++++++++++++ server/src/emails/album-update.email.tsx | 165 +++++++++++++++++ server/src/emails/welcome.email.tsx | 2 +- server/src/interfaces/job.interface.ts | 15 ++ .../src/interfaces/notification.interface.ts | 46 ++++- server/src/repositories/job.repository.ts | 2 + .../repositories/notification.repository.ts | 21 ++- server/src/services/album.service.spec.ts | 30 ++- server/src/services/album.service.ts | 12 ++ server/src/services/microservices.service.ts | 2 + server/src/services/notification.service.ts | 119 +++++++++++- 12 files changed, 580 insertions(+), 8 deletions(-) create mode 100644 server/src/emails/album-invite.email.tsx create mode 100644 server/src/emails/album-update.email.tsx diff --git a/docs/static/img/ios-app-store-badge.png b/docs/static/img/ios-app-store-badge.png index fd3d3a335831bdf7063397998ec73b03ac285f75..df5df80eeec5a2b32babba84a41146f2ec0604cc 100644 GIT binary patch literal 15634 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 S)$Y@cL9diF6e02!q5lU~zOkwR literal 1797 zcmV+g2m1JlP)tiN(yFN)DAsmb z@rhue8U!mcrGkU4nU<#}7&N(|K@m`^pioi3S_%bUgIc5_iYTDd>JTOH>jzFQH;{Wx zsI((F>&v}+?X&Lr-?R7HYpuOQ2qAytBzGP-q*|ym7iW}yi8V+5t z{Ngyh``4MjUMR!+c9+#+z(!t%;u?s^8l^OTH)8)9w4T5Z?+bc6d z?24-z1#S?G6uTnCN_saM;NB{VLu`3wqXF)yd|4=8->T67w-VbBVlTIBz_f3h)YJto z&_|1UTDo4llpghG^|W(}YZ?HYF7{%*fwL`jz;}{p&$7c@OT8$&Ue;FVkwV+cll7R7 z4+?TT(*WR>VlUSdxUH?&@3f@A&k2HYN(2{s-fHLRVwT`8mul`I!4flV@`DIC-5eXu zm9Dw_{LMlqNHfa|rpAF=nq{N=WXMr$k@fmTn&ozyD92wt=)gJJ5^929a**P{vz&E=`Fx_}<9?M)VBnk49Bu_p{TI^awjB)=AE_N82hlL9yKv{cu#PCmYXzm*bqvlEZ; zgSAWPFOC85y(xizm?A>jWsm&-;*mNj_7M(v-8ItwSHLf%1Wx{bZvqRC`hiZf1J9Xm zw)Zhh5ag*e?AQXongX{fMImUNJ=WOkD@XZ2Q!zXG+}^tm=q&gVetm3#*QI>JIZ4c& ziDK7bWPGuj=2Gq5uGD0m1VLBRNU(X9gSBU#RGS$@Vg`dW>VnFl)!f;0sajx)Yd+$qL#jR%y#=#<0E|qd06Z{)vB@E zR}(Bu!b{GrW%Yg^W3N312quE}z*Ip{h9|L4Fx)=-ZSxs!Z3yt#l)#@FTxV_YO&@3` zSnfbH3i^WkwQ-^%Y>0rFt+m$XU`>LJHeLd6DpBBssP)j09^injdI-ANjKLA`i*jYk z^oh6i6r5(G{m!mG@ELp#@-0W)b6$M@{TvhG!0*Wr1ZlS540+fV<;^W@CmQNC8S7|k zRf7){90ojtc&2qa5Fr zhlk>Ai=y{bTUyJv7@z7{3;1cA6ZOf#Un1cCg5VF>VT&!c*iJmJsu%EiDRK8Yys+EC z?!)@@jewu1<&rLzheh$WB{)BcPxvE7*8*OK+(_F!nhuA6XJDSfxO}f+$-|>5fF~c( zy8z|7MN){O2zXbtZa4Q9y2-=4Q6!aa2MM0~EjyhO|8@_?M8H!7K~ch>y#mFxfJa4t z(81KFTOHwVO5iLzli;qeqjL2KQKodTKC;hF%_66@P?io#ur}_r%5>CG8CE1%DZwgV z6SOeGCqC0%kdMWh3UXC@#{fYe|8&5>TEHEAY^Onj)|SL)b_4R^{yvC8ji)?lt^H9X zon1#Mtt#@+HS!cLj)rti81uffBjBaph}zzDM#5buTOCcAYE(rIs)K)_+CagmNW*@G z@nxxhbpK`c;zL!KQAdFExdnhs*SRu2G`hOVu=rw;D5ooQS*@0V3Vl;wRA}u+MaD*T z$~?@|#u!D$Bn;r1{Pz-fsW8X51V3@QLQ`Z#;mrhd{3^ca2F8(K9Q;8tQALiw^|x7Cta$KsH!FllxB*l)GOo(!>)+(xt2 zlB=S)k!_cjk7z_2;#uasQ<)J$STLk99C^qz{os*@5JD&$TC%&Kp4(tw&chA`N_LkG nO*qGMoF7~Ib9rISv8De2!9W0bC^sbC00000NkvXXu0mjfosM|r diff --git a/server/src/emails/album-invite.email.tsx b/server/src/emails/album-invite.email.tsx new file mode 100644 index 0000000000..cb2298b6d0 --- /dev/null +++ b/server/src/emails/album-invite.email.tsx @@ -0,0 +1,174 @@ +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 { AlbumInviteEmailProps } from 'src/interfaces/notification.interface'; + +export const AlbumInviteEmail = ({ + baseUrl, + albumName, + recipientName, + senderName, + albumId, + cid, +}: AlbumInviteEmailProps) => ( + + + You have been added to a shared album. + + +
+ Immich + + Hey {recipientName}! + + + {senderName} has added you to the album {albumName}. + + + {cid && ( + + + + + + )} + + + To view the album, open the link in a browser, or click the button below. + + + + + {baseUrl}/albums/{albumId} + + + + + + + + +
+ +
+ +
+ + + + Immich + + + Immich + + + +
+ + + Immich project is available under GNU AGPL v3 license. + +
+ + +); + +AlbumInviteEmail.PreviewProps = { + baseUrl: 'https://demo.immich.app', + albumName: 'Trip to Europe', + albumId: 'b63f6dae-e1c9-401b-9a85-9dbbf5612539', + senderName: 'Owner User', + recipientName: 'Guest User', + cid: '', +} as AlbumInviteEmailProps; + +export default AlbumInviteEmail; + +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', +}; diff --git a/server/src/emails/album-update.email.tsx b/server/src/emails/album-update.email.tsx new file mode 100644 index 0000000000..8dbd3fb7d9 --- /dev/null +++ b/server/src/emails/album-update.email.tsx @@ -0,0 +1,165 @@ +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 { AlbumUpdateEmailProps } from 'src/interfaces/notification.interface'; + +export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, cid }: AlbumUpdateEmailProps) => ( + + + New media has been added to a shared album. + + +
+ Immich + + Hey {recipientName}! + + + New media has been added to {albumName}, check it out! + + + {cid && ( + + + + + + )} + + + To view the album, open the link in a browser, or click the button below. + + + + + {baseUrl}/albums/{albumId} + + + + + + + + +
+ +
+ +
+ + + + Immich + + + Immich + + + +
+ + + Immich project is available under GNU AGPL v3 license. + +
+ + +); + +AlbumUpdateEmail.PreviewProps = { + baseUrl: 'https://demo.immich.app', + albumName: 'Trip to Europe', + albumId: 'b63f6dae-e1c9-401b-9a85-9dbbf5612539', + recipientName: 'Alex Tran', +} as AlbumUpdateEmailProps; + +export default AlbumUpdateEmail; + +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', +}; diff --git a/server/src/emails/welcome.email.tsx b/server/src/emails/welcome.email.tsx index 1e4bdd1496..a567226ae1 100644 --- a/server/src/emails/welcome.email.tsx +++ b/server/src/emails/welcome.email.tsx @@ -107,7 +107,7 @@ export const WelcomeEmail = ({ baseUrl, displayName, username, password }: Welco Immich diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 6393e3167a..3497a12969 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -1,3 +1,5 @@ +import { EmailImageAttachment } from 'src/interfaces/notification.interface'; + export enum QueueName { THUMBNAIL_GENERATION = 'thumbnailGeneration', METADATA_EXTRACTION = 'metadataExtraction', @@ -99,6 +101,8 @@ export enum JobName { // Notification NOTIFY_SIGNUP = 'notify-signup', + NOTIFY_ALBUM_INVITE = 'notify-album-invite', + NOTIFY_ALBUM_UPDATE = 'notify-album-update', SEND_EMAIL = 'notification-send-email', // Version check @@ -150,12 +154,21 @@ export interface IEmailJob { subject: string; html: string; text: string; + imageAttachments?: EmailImageAttachment[]; } export interface INotifySignupJob extends IEntityJob { tempPassword?: string; } +export interface INotifyAlbumInviteJob extends IEntityJob { + recipientId: string; +} + +export interface INotifyAlbumUpdateJob extends IEntityJob { + senderId: string; +} + export interface JobCounts { active: number; completed: number; @@ -246,6 +259,8 @@ export type JobItem = // Notification | { name: JobName.SEND_EMAIL; data: IEmailJob } + | { name: JobName.NOTIFY_ALBUM_INVITE; data: INotifyAlbumInviteJob } + | { name: JobName.NOTIFY_ALBUM_UPDATE; data: INotifyAlbumUpdateJob } | { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob } // Version check diff --git a/server/src/interfaces/notification.interface.ts b/server/src/interfaces/notification.interface.ts index 668aa1a6f6..d34173915c 100644 --- a/server/src/interfaces/notification.interface.ts +++ b/server/src/interfaces/notification.interface.ts @@ -1,5 +1,11 @@ export const INotificationRepository = 'INotificationRepository'; +export type EmailImageAttachment = { + filename: string; + path: string; + cid: string; +}; + export type SendEmailOptions = { from: string; to: string; @@ -7,6 +13,7 @@ export type SendEmailOptions = { subject: string; html: string; text: string; + imageAttachments?: EmailImageAttachment[]; smtp: SmtpOptions; }; @@ -19,18 +26,53 @@ export type SmtpOptions = { }; export enum EmailTemplate { + // AUTH WELCOME = 'welcome', RESET_PASSWORD = 'reset-password', + + // ALBUM + ALBUM_INVITE = 'album-invite', + ALBUM_UPDATE = 'album-update', } -export interface WelcomeEmailProps { +interface BaseEmailProps { baseUrl: string; +} + +export interface WelcomeEmailProps extends BaseEmailProps { displayName: string; username: string; password?: string; } -export type EmailRenderRequest = { template: EmailTemplate.WELCOME; data: WelcomeEmailProps }; +export interface AlbumInviteEmailProps extends BaseEmailProps { + albumName: string; + albumId: string; + senderName: string; + recipientName: string; + cid?: string; +} + +export interface AlbumUpdateEmailProps extends BaseEmailProps { + albumName: string; + albumId: string; + recipientName: string; + cid?: string; +} + +export type EmailRenderRequest = + | { + template: EmailTemplate.WELCOME; + data: WelcomeEmailProps; + } + | { + template: EmailTemplate.ALBUM_INVITE; + data: AlbumInviteEmailProps; + } + | { + template: EmailTemplate.ALBUM_UPDATE; + data: AlbumUpdateEmailProps; + }; export type SendEmailResponse = { messageId: string; diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 606549454d..c17a602577 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -85,6 +85,8 @@ export const JOBS_TO_QUEUE: Record = { // Notification [JobName.SEND_EMAIL]: QueueName.NOTIFICATION, + [JobName.NOTIFY_ALBUM_INVITE]: QueueName.NOTIFICATION, + [JobName.NOTIFY_ALBUM_UPDATE]: QueueName.NOTIFICATION, [JobName.NOTIFY_SIGNUP]: QueueName.NOTIFICATION, // Version check diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts index e22198de80..13f9a46bad 100644 --- a/server/src/repositories/notification.repository.ts +++ b/server/src/repositories/notification.repository.ts @@ -2,6 +2,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { render } from '@react-email/render'; import { createTransport } from 'nodemailer'; import React from 'react'; +import { AlbumInviteEmail } from 'src/emails/album-invite.email'; +import { AlbumUpdateEmail } from 'src/emails/album-update.email'; import { WelcomeEmail } from 'src/emails/welcome.email'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { @@ -37,11 +39,18 @@ export class NotificationRepository implements INotificationRepository { return { html, text }; } - sendEmail({ to, from, subject, html, text, smtp }: SendEmailOptions): Promise { + sendEmail({ to, from, subject, html, text, smtp, imageAttachments }: SendEmailOptions): Promise { this.logger.debug(`Sending email to ${to} with subject: ${subject}`); const transport = this.createTransport(smtp); + + const attachments = imageAttachments?.map((attachment) => ({ + filename: attachment.filename, + path: attachment.path, + cid: attachment.cid, + })); + try { - return transport.sendMail({ to, from, subject, html, text }); + return transport.sendMail({ to, from, subject, html, text, attachments }); } finally { transport.close(); } @@ -52,6 +61,14 @@ export class NotificationRepository implements INotificationRepository { case EmailTemplate.WELCOME: { return React.createElement(WelcomeEmail, data); } + + case EmailTemplate.ALBUM_INVITE: { + return React.createElement(AlbumInviteEmail, data); + } + + case EmailTemplate.ALBUM_UPDATE: { + return React.createElement(AlbumUpdateEmail, data); + } } } diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 3d20a6a559..7a2df77710 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -5,6 +5,7 @@ import { AlbumUserRole } from 'src/entities/album-user.entity'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AlbumService } from 'src/services/album.service'; import { albumStub } from 'test/fixtures/album.stub'; @@ -14,6 +15,7 @@ import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositorie import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked } from 'vitest'; @@ -24,6 +26,7 @@ describe(AlbumService.name, () => { let assetMock: Mocked; let userMock: Mocked; let albumUserMock: Mocked; + let jobMock: Mocked; beforeEach(() => { accessMock = newAccessRepositoryMock(); @@ -31,8 +34,9 @@ describe(AlbumService.name, () => { assetMock = newAssetRepositoryMock(); userMock = newUserRepositoryMock(); albumUserMock = newAlbumUserRepositoryMock(); + jobMock = newJobRepositoryMock(); - sut = new AlbumService(accessMock, albumMock, assetMock, userMock, albumUserMock); + sut = new AlbumService(accessMock, albumMock, assetMock, userMock, albumUserMock, jobMock); }); it('should work', () => { @@ -377,6 +381,14 @@ describe(AlbumService.name, () => { userId: authStub.user2.user.id, albumId: albumStub.sharedWithAdmin.id, }); + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.NOTIFY_ALBUM_INVITE, + data: { id: albumStub.sharedWithAdmin.id, recipientId: authStub.user2.user.id }, + }, + ], + ]); }); }); @@ -561,6 +573,14 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.NOTIFY_ALBUM_UPDATE, + data: { id: 'album-123', senderId: authStub.admin.user.id }, + }, + ], + ]); }); it('should not set the thumbnail if the album has one already', async () => { @@ -601,6 +621,14 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.NOTIFY_ALBUM_UPDATE, + data: { id: 'album-123', senderId: authStub.user1.user.id }, + }, + ], + ]); }); it('should not allow a shared user with viewer access to add assets', async () => { diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 643d060494..cf179bf289 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -21,6 +21,7 @@ import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @@ -33,6 +34,7 @@ export class AlbumService { @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IAlbumUserRepository) private albumUserRepository: IAlbumUserRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, ) { this.access = AccessCore.create(accessRepository); } @@ -188,6 +190,11 @@ export class AlbumService { }); } + await this.jobRepository.queue({ + name: JobName.NOTIFY_ALBUM_UPDATE, + data: { id, senderId: auth.user.id }, + }); + return results; } @@ -234,6 +241,11 @@ export class AlbumService { } await this.albumUserRepository.create({ userId: userId, albumId: id, role }); + + await this.jobRepository.queue({ + name: JobName.NOTIFY_ALBUM_INVITE, + data: { id: album.id, recipientId: user.id }, + }); } return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets); diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 1b6abe68f4..f175ed0459 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -90,6 +90,8 @@ export class MicroservicesService { [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_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data), + [JobName.NOTIFY_ALBUM_UPDATE]: (data) => this.notificationService.handleAlbumUpdate(data), [JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data), [JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(), }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 503fe4afdd..fb7853bb04 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,10 +1,21 @@ import { Inject, Injectable } from '@nestjs/common'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnServerEvent } from 'src/decorators'; +import { AlbumEntity } from 'src/entities/album.entity'; +import { IAlbumRepository } from 'src/interfaces/album.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ServerAsyncEvent, ServerAsyncEventMap } from 'src/interfaces/event.interface'; -import { IEmailJob, IJobRepository, INotifySignupJob, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { + IEmailJob, + IJobRepository, + INotifyAlbumInviteJob, + INotifyAlbumUpdateJob, + INotifySignupJob, + JobName, + JobStatus, +} from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.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'; @@ -18,6 +29,8 @@ export class NotificationService { @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, ) { this.logger.setContext(NotificationService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); @@ -70,6 +83,90 @@ export class NotificationService { return JobStatus.SUCCESS; } + async handleAlbumInvite({ id, recipientId }: INotifyAlbumInviteJob) { + const album = await this.albumRepository.getById(id, { withAssets: false }); + if (!album) { + return JobStatus.SKIPPED; + } + + const recipient = await this.userRepository.get(recipientId, { withDeleted: false }); + if (!recipient) { + return JobStatus.SKIPPED; + } + + const attachment = await this.getAlbumThumbnailAttachment(album); + + const { server } = await this.configCore.getConfig(); + const { html, text } = this.notificationRepository.renderEmail({ + template: EmailTemplate.ALBUM_INVITE, + data: { + baseUrl: server.externalDomain || 'http://localhost:2283', + albumId: album.id, + albumName: album.albumName, + senderName: album.owner.name, + recipientName: recipient.name, + cid: attachment ? attachment.cid : undefined, + }, + }); + + await this.jobRepository.queue({ + name: JobName.SEND_EMAIL, + data: { + to: recipient.email, + subject: `You have been added to a shared album - ${album.albumName}`, + html, + text, + imageAttachments: attachment ? [attachment] : undefined, + }, + }); + + return JobStatus.SUCCESS; + } + + async handleAlbumUpdate({ id, senderId }: INotifyAlbumUpdateJob) { + const album = await this.albumRepository.getById(id, { withAssets: false }); + + if (!album) { + return JobStatus.SKIPPED; + } + + const owner = await this.userRepository.get(album.ownerId, { withDeleted: false }); + if (!owner) { + return JobStatus.SKIPPED; + } + + const recipients = [...album.albumUsers.map((user) => user.user), owner].filter((user) => user.id !== senderId); + const attachment = await this.getAlbumThumbnailAttachment(album); + + const { server } = await this.configCore.getConfig(); + + for (const recipient of recipients) { + const { html, text } = this.notificationRepository.renderEmail({ + template: EmailTemplate.ALBUM_UPDATE, + data: { + baseUrl: server.externalDomain || 'http://localhost:2283', + albumId: album.id, + albumName: album.albumName, + recipientName: recipient.name, + cid: attachment ? attachment.cid : undefined, + }, + }); + + await this.jobRepository.queue({ + name: JobName.SEND_EMAIL, + data: { + to: recipient.email, + subject: `New media has been added to an album - ${album.albumName}`, + html, + text, + imageAttachments: attachment ? [attachment] : undefined, + }, + }); + } + + return JobStatus.SUCCESS; + } + async handleSendEmail(data: IEmailJob): Promise { const { notifications } = await this.configCore.getConfig(); if (!notifications.smtp.enabled) { @@ -85,6 +182,7 @@ export class NotificationService { from: notifications.smtp.from, replyTo: notifications.smtp.replyTo || notifications.smtp.from, smtp: notifications.smtp.transport, + imageAttachments: data.imageAttachments, }); if (!response) { @@ -95,4 +193,21 @@ export class NotificationService { return JobStatus.SUCCESS; } + + private async getAlbumThumbnailAttachment(album: AlbumEntity): Promise { + if (!album.albumThumbnailAssetId) { + return; + } + + const albumThumbnail = await this.assetRepository.getById(album.albumThumbnailAssetId); + if (!albumThumbnail?.thumbnailPath) { + return; + } + + return { + filename: 'album-thumbnail.jpg', + path: albumThumbnail.thumbnailPath, + cid: 'album-thumbnail', + }; + } }