feat: Notification Email Templates (#13940)

This commit is contained in:
Tim Van Onckelen 2024-12-04 21:26:02 +01:00 committed by GitHub
parent 4bf1b84cc2
commit 292182fa7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1136 additions and 105 deletions

View file

@ -146,6 +146,13 @@ export interface SystemConfig {
};
};
};
templates: {
email: {
welcomeTemplate: string;
albumInviteTemplate: string;
albumUpdateTemplate: string;
};
};
server: {
externalDomain: string;
loginPageMessage: string;
@ -313,6 +320,13 @@ export const defaults = Object.freeze<SystemConfig>({
},
},
},
templates: {
email: {
welcomeTemplate: '',
albumInviteTemplate: '',
albumUpdateTemplate: '',
},
},
user: {
deleteDelay: 7,
},

View file

@ -1,8 +1,9 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { TestEmailResponseDto } from 'src/dtos/notification.dto';
import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { EmailTemplate } from 'src/interfaces/notification.interface';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { NotificationService } from 'src/services/notification.service';
@ -17,4 +18,15 @@ export class NotificationController {
sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise<TestEmailResponseDto> {
return this.service.sendTestEmail(auth.user.id, dto);
}
@Post('templates/:name')
@HttpCode(HttpStatus.OK)
@Authenticated({ admin: true })
getNotificationTemplate(
@Auth() auth: AuthDto,
@Param('name') name: EmailTemplate,
@Body() dto: TemplateDto,
): Promise<TemplateResponseDto> {
return this.service.getTemplate(name, dto.template);
}
}

View file

@ -1,3 +1,13 @@
import { IsString } from 'class-validator';
export class TestEmailResponseDto {
messageId!: string;
}
export class TemplateResponseDto {
name!: string;
html!: string;
}
export class TemplateDto {
@IsString()
template!: string;
}

View file

@ -465,6 +465,24 @@ class SystemConfigNotificationsDto {
smtp!: SystemConfigSmtpDto;
}
class SystemConfigTemplateEmailsDto {
@IsString()
albumInviteTemplate!: string;
@IsString()
welcomeTemplate!: string;
@IsString()
albumUpdateTemplate!: string;
}
class SystemConfigTemplatesDto {
@Type(() => SystemConfigTemplateEmailsDto)
@ValidateNested()
@IsObject()
email!: SystemConfigTemplateEmailsDto;
}
class SystemConfigStorageTemplateDto {
@ValidateBoolean()
enabled!: boolean;
@ -636,6 +654,11 @@ export class SystemConfigDto implements SystemConfig {
@IsObject()
notifications!: SystemConfigNotificationsDto;
@Type(() => SystemConfigTemplatesDto)
@ValidateNested()
@IsObject()
templates!: SystemConfigTemplatesDto;
@Type(() => SystemConfigServerDto)
@ValidateNested()
@IsObject()

View file

@ -3,6 +3,7 @@ import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout';
import { AlbumInviteEmailProps } from 'src/interfaces/notification.interface';
import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const AlbumInviteEmail = ({
baseUrl,
@ -11,39 +12,64 @@ export const AlbumInviteEmail = ({
senderName,
albumId,
cid,
}: AlbumInviteEmailProps) => (
<ImmichLayout preview="You have been added to a shared album.">
<Text className="m-0">
Hey <strong>{recipientName}</strong>!
</Text>
customTemplate,
}: AlbumInviteEmailProps) => {
const variables = {
albumName,
recipientName,
senderName,
albumId,
baseUrl,
};
<Text>
{senderName} has added you to the album <strong>{albumName}</strong>.
</Text>
const emailContent = customTemplate ? (
replaceTemplateTags(customTemplate, variables)
) : (
<>
<Text className="m-0">
Hey <strong>{recipientName}</strong>!
</Text>
{cid && (
<Section className="flex justify-center my-0">
<Img
className="max-w-[300px] w-full rounded-lg"
src={`cid:${cid}`}
style={{
boxShadow: 'rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px',
}}
/>
<Text>
{senderName} has added you to the album <strong>{albumName}</strong>.
</Text>
</>
);
return (
<ImmichLayout preview={customTemplate ? emailContent.toString() : 'You have been added to a shared album.'}>
{customTemplate && (
<Text className="m-0">
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
</Text>
)}
{!customTemplate && emailContent}
{cid && (
<Section className="flex justify-center my-0">
<Img
className="max-w-[300px] w-full rounded-lg"
src={`cid:${cid}`}
style={{
boxShadow: 'rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px',
}}
/>
</Section>
)}
<Section className="flex justify-center my-6">
<ImmichButton href={`${baseUrl}/albums/${albumId}`}>View Album</ImmichButton>
</Section>
)}
<Section className="flex justify-center my-6">
<ImmichButton href={`${baseUrl}/albums/${albumId}`}>View Album</ImmichButton>
</Section>
<Text className="text-xs">
If you cannot click the button use the link below to view the album.
<br />
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
</Text>
</ImmichLayout>
);
<Text className="text-xs">
If you cannot click the button use the link below to view the album.
<br />
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
</Text>
</ImmichLayout>
);
};
AlbumInviteEmail.PreviewProps = {
baseUrl: 'https://demo.immich.app',

View file

@ -3,47 +3,80 @@ import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout';
import { AlbumUpdateEmailProps } from 'src/interfaces/notification.interface';
import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, cid }: AlbumUpdateEmailProps) => (
<ImmichLayout preview="New media has been added to a shared album.">
<Text className="m-0">
Hey <strong>{recipientName}</strong>!
</Text>
export const AlbumUpdateEmail = ({
baseUrl,
albumName,
recipientName,
albumId,
cid,
customTemplate,
}: AlbumUpdateEmailProps) => {
const usableTemplateVariables = {
albumName,
recipientName,
albumId,
baseUrl,
};
<Text>
New media has been added to <strong>{albumName}</strong>,
<br /> check it out!
</Text>
const emailContent = customTemplate ? (
replaceTemplateTags(customTemplate, usableTemplateVariables)
) : (
<>
<Text className="m-0">
Hey <strong>{recipientName}</strong>!
</Text>
{cid && (
<Section className="flex justify-center my-0">
<Img
className="max-w-[300px] w-full rounded-lg"
src={`cid:${cid}`}
style={{
boxShadow: 'rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px',
}}
/>
<Text>
New media has been added to <strong>{albumName}</strong>,
<br /> check it out!
</Text>
</>
);
return (
<ImmichLayout preview={customTemplate ? emailContent.toString() : 'New media has been added to a shared album.'}>
{customTemplate && (
<Text className="m-0">
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
</Text>
)}
{!customTemplate && emailContent}
{cid && (
<Section className="flex justify-center my-0">
<Img
className="max-w-[300px] w-full rounded-lg"
src={`cid:${cid}`}
style={{
boxShadow: 'rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px',
}}
/>
</Section>
)}
<Section className="flex justify-center my-6">
<ImmichButton href={`${baseUrl}/albums/${albumId}`}>View Album</ImmichButton>
</Section>
)}
<Section className="flex justify-center my-6">
<ImmichButton href={`${baseUrl}/albums/${albumId}`}>View Album</ImmichButton>
</Section>
<Text className="text-xs">
If you cannot click the button use the link below to view the album.
<br />
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
</Text>
</ImmichLayout>
);
<Text className="text-xs">
If you cannot click the button use the link below to view the album.
<br />
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
</Text>
</ImmichLayout>
);
};
AlbumUpdateEmail.PreviewProps = {
baseUrl: 'https://demo.immich.app',
albumName: 'Trip to Europe',
albumId: 'b63f6dae-e1c9-401b-9a85-9dbbf5612539',
recipientName: 'Alan Turing',
cid: '',
customTemplate: '',
} as AlbumUpdateEmailProps;
export default AlbumUpdateEmail;

View file

@ -3,36 +3,62 @@ import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout';
import { WelcomeEmailProps } from 'src/interfaces/notification.interface';
import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const WelcomeEmail = ({ baseUrl, displayName, username, password }: WelcomeEmailProps) => (
<ImmichLayout preview="You have been invited to a new Immich instance.">
<Text className="m-0">
Hey <strong>{displayName}</strong>!
</Text>
export const WelcomeEmail = ({ baseUrl, displayName, username, password, customTemplate }: WelcomeEmailProps) => {
const usableTemplateVariables = {
displayName,
username,
password,
baseUrl,
};
<Text>A new account has been created for you.</Text>
const emailContent = customTemplate ? (
replaceTemplateTags(customTemplate, usableTemplateVariables)
) : (
<>
<Text className="m-0">
Hey <strong>{displayName}</strong>!
</Text>
<Text>
<strong>Username</strong>: {username}
{password && (
<>
<br />
<strong>Password</strong>: {password}
</>
<Text>A new account has been created for you.</Text>
<Text>
<strong>Username</strong>: {username}
{password && (
<>
<br />
<strong>Password</strong>: {password}
</>
)}
</Text>
</>
);
return (
<ImmichLayout
preview={customTemplate ? emailContent.toString() : 'You have been invited to a new Immich instance.'}
>
{customTemplate && (
<Text className="m-0">
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
</Text>
)}
</Text>
<Section className="flex justify-center my-6">
<ImmichButton href={`${baseUrl}/auth/login`}>Login</ImmichButton>
</Section>
{!customTemplate && emailContent}
<Text className="text-xs">
If you cannot click the button use the link below to proceed with first login.
<br />
<Link href={baseUrl}>{baseUrl}</Link>
</Text>
</ImmichLayout>
);
<Section className="flex justify-center my-6">
<ImmichButton href={`${baseUrl}/auth/login`}>Login</ImmichButton>
</Section>
<Text className="text-xs">
If you cannot click the button use the link below to proceed with first login.
<br />
<Link href={baseUrl}>{baseUrl}</Link>
</Text>
</ImmichLayout>
);
};
WelcomeEmail.PreviewProps = {
baseUrl: 'https://demo.immich.app/auth/login',

View file

@ -39,6 +39,7 @@ export enum EmailTemplate {
interface BaseEmailProps {
baseUrl: string;
customTemplate?: string;
}
export interface TestEmailProps extends BaseEmailProps {
@ -70,18 +71,22 @@ export type EmailRenderRequest =
| {
template: EmailTemplate.TEST_EMAIL;
data: TestEmailProps;
customTemplate: string;
}
| {
template: EmailTemplate.WELCOME;
data: WelcomeEmailProps;
customTemplate: string;
}
| {
template: EmailTemplate.ALBUM_INVITE;
data: AlbumInviteEmailProps;
customTemplate: string;
}
| {
template: EmailTemplate.ALBUM_UPDATE;
data: AlbumUpdateEmailProps;
customTemplate: string;
};
export type SendEmailResponse = {

View file

@ -21,6 +21,7 @@ describe(NotificationRepository.name, () => {
const request: EmailRenderRequest = {
template: EmailTemplate.TEST_EMAIL,
data: { displayName: 'Alen Turing', baseUrl: 'http://localhost' },
customTemplate: '',
};
const result = await sut.renderEmail(request);
@ -33,6 +34,7 @@ describe(NotificationRepository.name, () => {
const request: EmailRenderRequest = {
template: EmailTemplate.WELCOME,
data: { displayName: 'Alen Turing', username: 'turing', baseUrl: 'http://localhost' },
customTemplate: '',
};
const result = await sut.renderEmail(request);
@ -51,6 +53,7 @@ describe(NotificationRepository.name, () => {
recipientName: 'Jane',
baseUrl: 'http://localhost',
},
customTemplate: '',
};
const result = await sut.renderEmail(request);
@ -63,6 +66,7 @@ describe(NotificationRepository.name, () => {
const request: EmailRenderRequest = {
template: EmailTemplate.ALBUM_UPDATE,
data: { albumName: 'Holiday', albumId: '123', recipientName: 'Jane', baseUrl: 'http://localhost' },
customTemplate: '',
};
const result = await sut.renderEmail(request);

View file

@ -55,22 +55,22 @@ export class NotificationRepository implements INotificationRepository {
}
}
private render({ template, data }: EmailRenderRequest): React.FunctionComponentElement<any> {
private render({ template, data, customTemplate }: EmailRenderRequest): React.FunctionComponentElement<any> {
switch (template) {
case EmailTemplate.TEST_EMAIL: {
return React.createElement(TestEmail, data);
return React.createElement(TestEmail, { ...data, customTemplate });
}
case EmailTemplate.WELCOME: {
return React.createElement(WelcomeEmail, data);
return React.createElement(WelcomeEmail, { ...data, customTemplate });
}
case EmailTemplate.ALBUM_INVITE: {
return React.createElement(AlbumInviteEmail, data);
return React.createElement(AlbumInviteEmail, { ...data, customTemplate });
}
case EmailTemplate.ALBUM_UPDATE: {
return React.createElement(AlbumUpdateEmail, data);
return React.createElement(AlbumUpdateEmail, { ...data, customTemplate });
}
}
}

View file

@ -140,7 +140,7 @@ export class NotificationService extends BaseService {
setTimeout(() => this.eventRepository.clientSend('on_session_delete', sessionId, sessionId), 500);
}
async sendTestEmail(id: string, dto: SystemConfigSmtpDto) {
async sendTestEmail(id: string, dto: SystemConfigSmtpDto, tempTemplate?: string) {
const user = await this.userRepository.get(id, { withDeleted: false });
if (!user) {
throw new Error('User not found');
@ -160,8 +160,8 @@ export class NotificationService extends BaseService {
baseUrl: getExternalDomain(server, port),
displayName: user.name,
},
customTemplate: tempTemplate!,
});
const { messageId } = await this.notificationRepository.sendEmail({
to: user.email,
subject: 'Test email from Immich',
@ -175,6 +175,69 @@ export class NotificationService extends BaseService {
return { messageId };
}
async getTemplate(name: EmailTemplate, customTemplate: string) {
const { server, templates } = await this.getConfig({ withCache: false });
const { port } = this.configRepository.getEnv();
let templateResponse = '';
switch (name) {
case EmailTemplate.WELCOME: {
const { html: _welcomeHtml } = await this.notificationRepository.renderEmail({
template: EmailTemplate.WELCOME,
data: {
baseUrl: getExternalDomain(server, port),
displayName: 'John Doe',
username: 'john@doe.com',
password: 'thisIsAPassword123',
},
customTemplate: customTemplate || templates.email.welcomeTemplate,
});
templateResponse = _welcomeHtml;
break;
}
case EmailTemplate.ALBUM_UPDATE: {
const { html: _updateAlbumHtml } = await this.notificationRepository.renderEmail({
template: EmailTemplate.ALBUM_UPDATE,
data: {
baseUrl: getExternalDomain(server, port),
albumId: '1',
albumName: 'Favorite Photos',
recipientName: 'Jane Doe',
cid: undefined,
},
customTemplate: customTemplate || templates.email.albumInviteTemplate,
});
templateResponse = _updateAlbumHtml;
break;
}
case EmailTemplate.ALBUM_INVITE: {
const { html } = await this.notificationRepository.renderEmail({
template: EmailTemplate.ALBUM_INVITE,
data: {
baseUrl: getExternalDomain(server, port),
albumId: '1',
albumName: "John Doe's Favorites",
senderName: 'John Doe',
recipientName: 'Jane Doe',
cid: undefined,
},
customTemplate: customTemplate || templates.email.albumInviteTemplate,
});
templateResponse = html;
break;
}
default: {
templateResponse = '';
break;
}
}
return { name, html: templateResponse };
}
@OnJob({ name: JobName.NOTIFY_SIGNUP, queue: QueueName.NOTIFICATION })
async handleUserSignup({ id, tempPassword }: JobOf<JobName.NOTIFY_SIGNUP>) {
const user = await this.userRepository.get(id, { withDeleted: false });
@ -182,7 +245,7 @@ export class NotificationService extends BaseService {
return JobStatus.SKIPPED;
}
const { server } = await this.getConfig({ withCache: true });
const { server, templates } = await this.getConfig({ withCache: true });
const { port } = this.configRepository.getEnv();
const { html, text } = await this.notificationRepository.renderEmail({
template: EmailTemplate.WELCOME,
@ -192,6 +255,7 @@ export class NotificationService extends BaseService {
username: user.email,
password: tempPassword,
},
customTemplate: templates.email.welcomeTemplate,
});
await this.jobRepository.queue({
@ -227,7 +291,7 @@ export class NotificationService extends BaseService {
const attachment = await this.getAlbumThumbnailAttachment(album);
const { server } = await this.getConfig({ withCache: false });
const { server, templates } = await this.getConfig({ withCache: false });
const { port } = this.configRepository.getEnv();
const { html, text } = await this.notificationRepository.renderEmail({
template: EmailTemplate.ALBUM_INVITE,
@ -239,6 +303,7 @@ export class NotificationService extends BaseService {
recipientName: recipient.name,
cid: attachment ? attachment.cid : undefined,
},
customTemplate: templates.email.albumInviteTemplate,
});
await this.jobRepository.queue({
@ -273,7 +338,7 @@ export class NotificationService extends BaseService {
);
const attachment = await this.getAlbumThumbnailAttachment(album);
const { server } = await this.getConfig({ withCache: false });
const { server, templates } = await this.getConfig({ withCache: false });
const { port } = this.configRepository.getEnv();
for (const recipient of recipients) {
@ -297,6 +362,7 @@ export class NotificationService extends BaseService {
recipientName: recipient.name,
cid: attachment ? attachment.cid : undefined,
},
customTemplate: templates.email.albumUpdateTemplate,
});
await this.jobRepository.queue({

View file

@ -190,6 +190,13 @@ const updatedConfig = Object.freeze<SystemConfig>({
},
},
},
templates: {
email: {
albumInviteTemplate: '',
welcomeTemplate: '',
albumUpdateTemplate: '',
},
},
});
describe(SystemConfigService.name, () => {

View file

@ -0,0 +1,5 @@
export const replaceTemplateTags = (template: string, variables: Record<string, string | undefined>) => {
return template.replaceAll(/{(.*?)}/g, (_, key) => {
return variables[key] || `{${key}}`;
});
};