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) => (
+
+
+
+
+
+
+ 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 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) => (
+
+
+
+
+
+
+ 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 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
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',
+ };
+ }
}