diff --git a/docs/static/img/ios-app-store-badge.png b/docs/static/img/ios-app-store-badge.png index fd3d3a3358..df5df80eee 100644 Binary files a/docs/static/img/ios-app-store-badge.png and b/docs/static/img/ios-app-store-badge.png differ 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', + }; + } }