1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-26 10:50:29 +02:00

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 638 additions and 104 deletions

View File

@ -19,3 +19,9 @@ You can use [this guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server.
Users can manage their email notification settings from their account settings page on the web. They can choose to turn email notifications on or off for the following events:
<img src={require('./img/user-notifications-settings.png').default} width="80%" title="User notification settings" />
## Notification templates
You can override the default notification text with custom templates in HTML format. You can use tags to show dynamic tags in your templates.
<img src={require('./img/user-notifications-templates.png').default} width="80%" title="User notification templates" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

View File

@ -157,6 +157,10 @@ Immich supports [Reverse Geocoding](/docs/features/reverse-geocoding) using data
SMTP server setup, for user creation notifications, new albums, etc. More information can be found [here](/docs/administration/email-notification)
## Notification Templates
Override the default notifications text with notification templates. More information can be found [here](/docs/administration/email-notification)
## Server Settings
### External Domain

View File

@ -252,6 +252,16 @@
"storage_template_user_label": "<code>{label}</code> is the user's Storage Label",
"system_settings": "System Settings",
"tag_cleanup_job": "Tag cleanup",
"template_email_preview": "Preview",
"template_email_settings": "Email Templates",
"template_email_settings_description": "Manage custom email notification templates",
"template_email_welcome": "Welcome email template",
"template_email_invite_album": "Invite Album Template",
"template_email_update_album": "Update Album Template",
"template_settings": "Notification Templates",
"template_settings_description": "Manage custom templates for notifications.",
"template_email_if_empty": "If the template is empty, the default email will be used.",
"template_email_available_tags": "You can use the following variables in your template: {tags}",
"theme_custom_css_settings": "Custom CSS",
"theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.",
"theme_settings": "Theme Settings",
@ -1325,4 +1335,4 @@
"zoom_image": "Zoom Image",
"timeline": "Timeline",
"total": "Total"
}
}

View File

@ -247,6 +247,16 @@
"storage_template_user_label": "<code>{label}</code> is het opslaglabel van de gebruiker",
"system_settings": "Systeeminstellingen",
"tag_cleanup_job": "Tag opschoning",
"template_email_settings": "Email",
"template_email_settings_description": "Beheer aangepaste email melding sjablonen",
"template_email_preview": "Voorbeeld",
"template_email_welcome": "Welkom email sjabloon",
"template_email_invite_album": "Uitgenodigd in album sjabloon",
"template_email_update_album": "Update in album sjabloon",
"template_settings": "Melding sjablonen",
"template_settings_description": "Beheer aangepast sjablonen voor meldingen.",
"template_email_if_empty": "Wanneer het sjabloon leeg is, wordt de standaard mail gebruikt.",
"template_email_available_tags": "Je kan de volgende tags gebruiken in een template: {tags}",
"theme_custom_css_settings": "Aangepaste CSS",
"theme_custom_css_settings_description": "Met Cascading Style Sheets kan het ontwerp van Immich worden aangepast.",
"theme_settings": "Thema instellingen",

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/lib/model/template_dto.dart generated Normal file

Binary file not shown.

Binary file not shown.

View File

@ -3430,6 +3430,57 @@
]
}
},
"/notifications/templates/{name}": {
"post": {
"operationId": "getNotificationTemplate",
"parameters": [
{
"name": "name",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemplateDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemplateResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications"
]
}
},
"/notifications/test-email": {
"post": {
"operationId": "sendTestEmail",
@ -11538,6 +11589,9 @@
"storageTemplate": {
"$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
},
"templates": {
"$ref": "#/components/schemas/SystemConfigTemplatesDto"
},
"theme": {
"$ref": "#/components/schemas/SystemConfigThemeDto"
},
@ -11565,6 +11619,7 @@
"reverseGeocoding",
"server",
"storageTemplate",
"templates",
"theme",
"trash",
"user"
@ -12111,6 +12166,25 @@
],
"type": "object"
},
"SystemConfigTemplateEmailsDto": {
"properties": {
"albumInviteTemplate": {
"type": "string"
},
"albumUpdateTemplate": {
"type": "string"
},
"welcomeTemplate": {
"type": "string"
}
},
"required": [
"albumInviteTemplate",
"albumUpdateTemplate",
"welcomeTemplate"
],
"type": "object"
},
"SystemConfigTemplateStorageOptionDto": {
"properties": {
"dayOptions": {
@ -12174,6 +12248,17 @@
],
"type": "object"
},
"SystemConfigTemplatesDto": {
"properties": {
"email": {
"$ref": "#/components/schemas/SystemConfigTemplateEmailsDto"
}
},
"required": [
"email"
],
"type": "object"
},
"SystemConfigThemeDto": {
"properties": {
"customCss": {
@ -12352,6 +12437,32 @@
},
"type": "object"
},
"TemplateDto": {
"properties": {
"template": {
"type": "string"
}
},
"required": [
"template"
],
"type": "object"
},
"TemplateResponseDto": {
"properties": {
"html": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"html",
"name"
],
"type": "object"
},
"TestEmailResponseDto": {
"properties": {
"messageId": {

View File

@ -634,6 +634,13 @@ export type MemoryUpdateDto = {
memoryAt?: string;
seenAt?: string;
};
export type TemplateDto = {
template: string;
};
export type TemplateResponseDto = {
html: string;
name: string;
};
export type SystemConfigSmtpTransportDto = {
host: string;
ignoreCert: boolean;
@ -1232,6 +1239,14 @@ export type SystemConfigStorageTemplateDto = {
hashVerificationEnabled: boolean;
template: string;
};
export type SystemConfigTemplateEmailsDto = {
albumInviteTemplate: string;
albumUpdateTemplate: string;
welcomeTemplate: string;
};
export type SystemConfigTemplatesDto = {
email: SystemConfigTemplateEmailsDto;
};
export type SystemConfigThemeDto = {
customCss: string;
};
@ -1259,6 +1274,7 @@ export type SystemConfigDto = {
reverseGeocoding: SystemConfigReverseGeocodingDto;
server: SystemConfigServerDto;
storageTemplate: SystemConfigStorageTemplateDto;
templates: SystemConfigTemplatesDto;
theme: SystemConfigThemeDto;
trash: SystemConfigTrashDto;
user: SystemConfigUserDto;
@ -2227,6 +2243,19 @@ export function addMemoryAssets({ id, bulkIdsDto }: {
body: bulkIdsDto
})));
}
export function getNotificationTemplate({ name, templateDto }: {
name: string;
templateDto: TemplateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TemplateResponseDto;
}>(`/notifications/templates/${encodeURIComponent(name)}`, oazapfts.json({
...opts,
method: "POST",
body: templateDto
})));
}
export function sendTestEmail({ systemConfigSmtpDto }: {
systemConfigSmtpDto: SystemConfigSmtpDto;
}, opts?: Oazapfts.RequestOpts) {

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}}`;
});
};

2
web/package-lock.json generated
View File

@ -23,7 +23,7 @@
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
"luxon": "^3.4.4",
"socket.io-client": "^4.7.5",
"socket.io-client": "~4.7.5",
"svelte-gestures": "^5.0.4",
"svelte-i18n": "^4.0.1",
"svelte-local-storage-store": "^0.6.4",

View File

@ -17,6 +17,7 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { handleError } from '$lib/utils/handle-error';
import { SettingInputFieldType } from '$lib/constants';
import TemplateSettings from '$lib/components/admin-page/settings/template-settings/template-settings.svelte';
interface Props {
savedConfig: SystemConfigDto;
@ -162,13 +163,14 @@
</div>
</SettingAccordion>
</div>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['notifications'] })}
onSave={() => onSave({ notifications: config.notifications })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</form>
</div>
<TemplateSettings {defaultConfig} {config} {savedConfig} {onReset} {onSave} />
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['notifications', 'templates'] })}
onSave={() => onSave({ notifications: config.notifications, templates: config.templates })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</div>

View File

@ -0,0 +1,131 @@
<script lang="ts">
import { type SystemConfigDto, type SystemConfigTemplateEmailsDto, getNotificationTemplate } from '@immich/sdk';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiEyeOutline } from '@mdi/js';
import { handleError } from '$lib/utils/handle-error';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, config = $bindable() }: Props = $props();
let htmlPreview = $state('');
let loadingPreview = $state(false);
const getTemplate = async (name: string, template: string) => {
try {
loadingPreview = true;
const result = await getNotificationTemplate({ name, templateDto: { template } });
htmlPreview = result.html;
} catch (error) {
handleError(error, 'Could not load template.');
} finally {
loadingPreview = false;
}
};
const closePreviewModal = () => {
htmlPreview = '';
};
const templateConfigs = [
{
label: $t('admin.template_email_welcome'),
templateKey: 'welcomeTemplate' as const,
descriptionTags: '{username}, {password}, {displayName}, {baseUrl}',
templateName: 'welcome',
},
{
label: $t('admin.template_email_invite_album'),
templateKey: 'albumInviteTemplate' as const,
descriptionTags: '{senderName}, {recipientName}, {albumId}, {albumName}, {baseUrl}',
templateName: 'album-invite',
},
{
label: $t('admin.template_email_update_album'),
templateKey: 'albumUpdateTemplate' as const,
descriptionTags: '{recipientName}, {albumId}, {albumName}, {baseUrl}',
templateName: 'album-update',
},
];
const isEdited = (templateKey: keyof SystemConfigTemplateEmailsDto) =>
config.templates.email[templateKey] !== savedConfig.templates.email[templateKey];
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit} class="mt-4">
<div class="flex flex-col gap-4">
<SettingAccordion
key="templates"
title={$t('admin.template_email_settings')}
subtitle={$t('admin.template_settings_description')}
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.template_email_if_empty">
{$t('admin.template_email_if_empty')}
</FormatMessage>
</p>
<hr />
{#if loadingPreview}
<LoadingSpinner />
{/if}
{#each templateConfigs as { label, templateKey, descriptionTags, templateName }}
<SettingTextarea
{label}
description={$t('admin.template_email_available_tags', { values: { tags: descriptionTags } })}
bind:value={config.templates.email[templateKey]}
isEdited={isEdited(templateKey)}
disabled={!config.notifications.smtp.enabled}
/>
<div class="flex justify-between">
<Button
size="sm"
onclick={() => getTemplate(templateName, config.templates.email[templateKey])}
title={$t('admin.template_email_preview')}
>
<Icon path={mdiEyeOutline} class="mr-1" />
{$t('admin.template_email_preview')}
</Button>
</div>
{/each}
</div>
</SettingAccordion>
</div>
{#if htmlPreview}
<FullScreenModal title={$t('admin.template_email_preview')} onClose={closePreviewModal} width="wide">
<div style="position:relative; width:100%; height:90vh; overflow: hidden">
<iframe
title={$t('admin.template_email_preview')}
srcdoc={htmlPreview}
style="width: 100%; height: 100%; border: none; overflow:hidden; position: absolute; top: 0; left: 0;"
></iframe>
</div>
</FullScreenModal>
{/if}
</form>
</div>
</div>