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: 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" /> <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) 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 ## Server Settings
### External Domain ### External Domain

View File

@ -252,6 +252,16 @@
"storage_template_user_label": "<code>{label}</code> is the user's Storage Label", "storage_template_user_label": "<code>{label}</code> is the user's Storage Label",
"system_settings": "System Settings", "system_settings": "System Settings",
"tag_cleanup_job": "Tag cleanup", "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": "Custom CSS",
"theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.", "theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.",
"theme_settings": "Theme Settings", "theme_settings": "Theme Settings",
@ -1325,4 +1335,4 @@
"zoom_image": "Zoom Image", "zoom_image": "Zoom Image",
"timeline": "Timeline", "timeline": "Timeline",
"total": "Total" "total": "Total"
} }

View File

@ -247,6 +247,16 @@
"storage_template_user_label": "<code>{label}</code> is het opslaglabel van de gebruiker", "storage_template_user_label": "<code>{label}</code> is het opslaglabel van de gebruiker",
"system_settings": "Systeeminstellingen", "system_settings": "Systeeminstellingen",
"tag_cleanup_job": "Tag opschoning", "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": "Aangepaste CSS",
"theme_custom_css_settings_description": "Met Cascading Style Sheets kan het ontwerp van Immich worden aangepast.", "theme_custom_css_settings_description": "Met Cascading Style Sheets kan het ontwerp van Immich worden aangepast.",
"theme_settings": "Thema instellingen", "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": { "/notifications/test-email": {
"post": { "post": {
"operationId": "sendTestEmail", "operationId": "sendTestEmail",
@ -11538,6 +11589,9 @@
"storageTemplate": { "storageTemplate": {
"$ref": "#/components/schemas/SystemConfigStorageTemplateDto" "$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
}, },
"templates": {
"$ref": "#/components/schemas/SystemConfigTemplatesDto"
},
"theme": { "theme": {
"$ref": "#/components/schemas/SystemConfigThemeDto" "$ref": "#/components/schemas/SystemConfigThemeDto"
}, },
@ -11565,6 +11619,7 @@
"reverseGeocoding", "reverseGeocoding",
"server", "server",
"storageTemplate", "storageTemplate",
"templates",
"theme", "theme",
"trash", "trash",
"user" "user"
@ -12111,6 +12166,25 @@
], ],
"type": "object" "type": "object"
}, },
"SystemConfigTemplateEmailsDto": {
"properties": {
"albumInviteTemplate": {
"type": "string"
},
"albumUpdateTemplate": {
"type": "string"
},
"welcomeTemplate": {
"type": "string"
}
},
"required": [
"albumInviteTemplate",
"albumUpdateTemplate",
"welcomeTemplate"
],
"type": "object"
},
"SystemConfigTemplateStorageOptionDto": { "SystemConfigTemplateStorageOptionDto": {
"properties": { "properties": {
"dayOptions": { "dayOptions": {
@ -12174,6 +12248,17 @@
], ],
"type": "object" "type": "object"
}, },
"SystemConfigTemplatesDto": {
"properties": {
"email": {
"$ref": "#/components/schemas/SystemConfigTemplateEmailsDto"
}
},
"required": [
"email"
],
"type": "object"
},
"SystemConfigThemeDto": { "SystemConfigThemeDto": {
"properties": { "properties": {
"customCss": { "customCss": {
@ -12352,6 +12437,32 @@
}, },
"type": "object" "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": { "TestEmailResponseDto": {
"properties": { "properties": {
"messageId": { "messageId": {

View File

@ -634,6 +634,13 @@ export type MemoryUpdateDto = {
memoryAt?: string; memoryAt?: string;
seenAt?: string; seenAt?: string;
}; };
export type TemplateDto = {
template: string;
};
export type TemplateResponseDto = {
html: string;
name: string;
};
export type SystemConfigSmtpTransportDto = { export type SystemConfigSmtpTransportDto = {
host: string; host: string;
ignoreCert: boolean; ignoreCert: boolean;
@ -1232,6 +1239,14 @@ export type SystemConfigStorageTemplateDto = {
hashVerificationEnabled: boolean; hashVerificationEnabled: boolean;
template: string; template: string;
}; };
export type SystemConfigTemplateEmailsDto = {
albumInviteTemplate: string;
albumUpdateTemplate: string;
welcomeTemplate: string;
};
export type SystemConfigTemplatesDto = {
email: SystemConfigTemplateEmailsDto;
};
export type SystemConfigThemeDto = { export type SystemConfigThemeDto = {
customCss: string; customCss: string;
}; };
@ -1259,6 +1274,7 @@ export type SystemConfigDto = {
reverseGeocoding: SystemConfigReverseGeocodingDto; reverseGeocoding: SystemConfigReverseGeocodingDto;
server: SystemConfigServerDto; server: SystemConfigServerDto;
storageTemplate: SystemConfigStorageTemplateDto; storageTemplate: SystemConfigStorageTemplateDto;
templates: SystemConfigTemplatesDto;
theme: SystemConfigThemeDto; theme: SystemConfigThemeDto;
trash: SystemConfigTrashDto; trash: SystemConfigTrashDto;
user: SystemConfigUserDto; user: SystemConfigUserDto;
@ -2227,6 +2243,19 @@ export function addMemoryAssets({ id, bulkIdsDto }: {
body: 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 }: { export function sendTestEmail({ systemConfigSmtpDto }: {
systemConfigSmtpDto: SystemConfigSmtpDto; systemConfigSmtpDto: SystemConfigSmtpDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {

View File

@ -146,6 +146,13 @@ export interface SystemConfig {
}; };
}; };
}; };
templates: {
email: {
welcomeTemplate: string;
albumInviteTemplate: string;
albumUpdateTemplate: string;
};
};
server: { server: {
externalDomain: string; externalDomain: string;
loginPageMessage: string; loginPageMessage: string;
@ -313,6 +320,13 @@ export const defaults = Object.freeze<SystemConfig>({
}, },
}, },
}, },
templates: {
email: {
welcomeTemplate: '',
albumInviteTemplate: '',
albumUpdateTemplate: '',
},
},
user: { user: {
deleteDelay: 7, 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 { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto'; 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 { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { EmailTemplate } from 'src/interfaces/notification.interface';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { NotificationService } from 'src/services/notification.service'; import { NotificationService } from 'src/services/notification.service';
@ -17,4 +18,15 @@ export class NotificationController {
sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise<TestEmailResponseDto> { sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise<TestEmailResponseDto> {
return this.service.sendTestEmail(auth.user.id, dto); 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 { export class TestEmailResponseDto {
messageId!: string; 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; smtp!: SystemConfigSmtpDto;
} }
class SystemConfigTemplateEmailsDto {
@IsString()
albumInviteTemplate!: string;
@IsString()
welcomeTemplate!: string;
@IsString()
albumUpdateTemplate!: string;
}
class SystemConfigTemplatesDto {
@Type(() => SystemConfigTemplateEmailsDto)
@ValidateNested()
@IsObject()
email!: SystemConfigTemplateEmailsDto;
}
class SystemConfigStorageTemplateDto { class SystemConfigStorageTemplateDto {
@ValidateBoolean() @ValidateBoolean()
enabled!: boolean; enabled!: boolean;
@ -636,6 +654,11 @@ export class SystemConfigDto implements SystemConfig {
@IsObject() @IsObject()
notifications!: SystemConfigNotificationsDto; notifications!: SystemConfigNotificationsDto;
@Type(() => SystemConfigTemplatesDto)
@ValidateNested()
@IsObject()
templates!: SystemConfigTemplatesDto;
@Type(() => SystemConfigServerDto) @Type(() => SystemConfigServerDto)
@ValidateNested() @ValidateNested()
@IsObject() @IsObject()

View File

@ -3,6 +3,7 @@ import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component'; import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout'; import ImmichLayout from 'src/emails/components/immich.layout';
import { AlbumInviteEmailProps } from 'src/interfaces/notification.interface'; import { AlbumInviteEmailProps } from 'src/interfaces/notification.interface';
import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const AlbumInviteEmail = ({ export const AlbumInviteEmail = ({
baseUrl, baseUrl,
@ -11,39 +12,64 @@ export const AlbumInviteEmail = ({
senderName, senderName,
albumId, albumId,
cid, cid,
}: AlbumInviteEmailProps) => ( customTemplate,
<ImmichLayout preview="You have been added to a shared album."> }: AlbumInviteEmailProps) => {
<Text className="m-0"> const variables = {
Hey <strong>{recipientName}</strong>! albumName,
</Text> recipientName,
senderName,
albumId,
baseUrl,
};
<Text> const emailContent = customTemplate ? (
{senderName} has added you to the album <strong>{albumName}</strong>. replaceTemplateTags(customTemplate, variables)
</Text> ) : (
<>
<Text className="m-0">
Hey <strong>{recipientName}</strong>!
</Text>
{cid && ( <Text>
<Section className="flex justify-center my-0"> {senderName} has added you to the album <strong>{albumName}</strong>.
<Img </Text>
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', 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>
)}
<Section className="flex justify-center my-6"> <Text className="text-xs">
<ImmichButton href={`${baseUrl}/albums/${albumId}`}>View Album</ImmichButton> If you cannot click the button use the link below to view the album.
</Section> <br />
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
<Text className="text-xs"> </Text>
If you cannot click the button use the link below to view the album. </ImmichLayout>
<br /> );
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link> };
</Text>
</ImmichLayout>
);
AlbumInviteEmail.PreviewProps = { AlbumInviteEmail.PreviewProps = {
baseUrl: 'https://demo.immich.app', 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 { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout'; import ImmichLayout from 'src/emails/components/immich.layout';
import { AlbumUpdateEmailProps } from 'src/interfaces/notification.interface'; import { AlbumUpdateEmailProps } from 'src/interfaces/notification.interface';
import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, cid }: AlbumUpdateEmailProps) => ( export const AlbumUpdateEmail = ({
<ImmichLayout preview="New media has been added to a shared album."> baseUrl,
<Text className="m-0"> albumName,
Hey <strong>{recipientName}</strong>! recipientName,
</Text> albumId,
cid,
customTemplate,
}: AlbumUpdateEmailProps) => {
const usableTemplateVariables = {
albumName,
recipientName,
albumId,
baseUrl,
};
<Text> const emailContent = customTemplate ? (
New media has been added to <strong>{albumName}</strong>, replaceTemplateTags(customTemplate, usableTemplateVariables)
<br /> check it out! ) : (
</Text> <>
<Text className="m-0">
Hey <strong>{recipientName}</strong>!
</Text>
{cid && ( <Text>
<Section className="flex justify-center my-0"> New media has been added to <strong>{albumName}</strong>,
<Img <br /> check it out!
className="max-w-[300px] w-full rounded-lg" </Text>
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',
}} 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>
)}
<Section className="flex justify-center my-6"> <Text className="text-xs">
<ImmichButton href={`${baseUrl}/albums/${albumId}`}>View Album</ImmichButton> If you cannot click the button use the link below to view the album.
</Section> <br />
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link>
<Text className="text-xs"> </Text>
If you cannot click the button use the link below to view the album. </ImmichLayout>
<br /> );
<Link href={`${baseUrl}/albums/${albumId}`}>{`${baseUrl}/albums/${albumId}`}</Link> };
</Text>
</ImmichLayout>
);
AlbumUpdateEmail.PreviewProps = { AlbumUpdateEmail.PreviewProps = {
baseUrl: 'https://demo.immich.app', baseUrl: 'https://demo.immich.app',
albumName: 'Trip to Europe', albumName: 'Trip to Europe',
albumId: 'b63f6dae-e1c9-401b-9a85-9dbbf5612539', albumId: 'b63f6dae-e1c9-401b-9a85-9dbbf5612539',
recipientName: 'Alan Turing', recipientName: 'Alan Turing',
cid: '',
customTemplate: '',
} as AlbumUpdateEmailProps; } as AlbumUpdateEmailProps;
export default AlbumUpdateEmail; export default AlbumUpdateEmail;

View File

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

View File

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

View File

@ -21,6 +21,7 @@ describe(NotificationRepository.name, () => {
const request: EmailRenderRequest = { const request: EmailRenderRequest = {
template: EmailTemplate.TEST_EMAIL, template: EmailTemplate.TEST_EMAIL,
data: { displayName: 'Alen Turing', baseUrl: 'http://localhost' }, data: { displayName: 'Alen Turing', baseUrl: 'http://localhost' },
customTemplate: '',
}; };
const result = await sut.renderEmail(request); const result = await sut.renderEmail(request);
@ -33,6 +34,7 @@ describe(NotificationRepository.name, () => {
const request: EmailRenderRequest = { const request: EmailRenderRequest = {
template: EmailTemplate.WELCOME, template: EmailTemplate.WELCOME,
data: { displayName: 'Alen Turing', username: 'turing', baseUrl: 'http://localhost' }, data: { displayName: 'Alen Turing', username: 'turing', baseUrl: 'http://localhost' },
customTemplate: '',
}; };
const result = await sut.renderEmail(request); const result = await sut.renderEmail(request);
@ -51,6 +53,7 @@ describe(NotificationRepository.name, () => {
recipientName: 'Jane', recipientName: 'Jane',
baseUrl: 'http://localhost', baseUrl: 'http://localhost',
}, },
customTemplate: '',
}; };
const result = await sut.renderEmail(request); const result = await sut.renderEmail(request);
@ -63,6 +66,7 @@ describe(NotificationRepository.name, () => {
const request: EmailRenderRequest = { const request: EmailRenderRequest = {
template: EmailTemplate.ALBUM_UPDATE, template: EmailTemplate.ALBUM_UPDATE,
data: { albumName: 'Holiday', albumId: '123', recipientName: 'Jane', baseUrl: 'http://localhost' }, data: { albumName: 'Holiday', albumId: '123', recipientName: 'Jane', baseUrl: 'http://localhost' },
customTemplate: '',
}; };
const result = await sut.renderEmail(request); 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) { switch (template) {
case EmailTemplate.TEST_EMAIL: { case EmailTemplate.TEST_EMAIL: {
return React.createElement(TestEmail, data); return React.createElement(TestEmail, { ...data, customTemplate });
} }
case EmailTemplate.WELCOME: { case EmailTemplate.WELCOME: {
return React.createElement(WelcomeEmail, data); return React.createElement(WelcomeEmail, { ...data, customTemplate });
} }
case EmailTemplate.ALBUM_INVITE: { case EmailTemplate.ALBUM_INVITE: {
return React.createElement(AlbumInviteEmail, data); return React.createElement(AlbumInviteEmail, { ...data, customTemplate });
} }
case EmailTemplate.ALBUM_UPDATE: { 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); 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 }); const user = await this.userRepository.get(id, { withDeleted: false });
if (!user) { if (!user) {
throw new Error('User not found'); throw new Error('User not found');
@ -160,8 +160,8 @@ export class NotificationService extends BaseService {
baseUrl: getExternalDomain(server, port), baseUrl: getExternalDomain(server, port),
displayName: user.name, displayName: user.name,
}, },
customTemplate: tempTemplate!,
}); });
const { messageId } = await this.notificationRepository.sendEmail({ const { messageId } = await this.notificationRepository.sendEmail({
to: user.email, to: user.email,
subject: 'Test email from Immich', subject: 'Test email from Immich',
@ -175,6 +175,69 @@ export class NotificationService extends BaseService {
return { messageId }; 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 }) @OnJob({ name: JobName.NOTIFY_SIGNUP, queue: QueueName.NOTIFICATION })
async handleUserSignup({ id, tempPassword }: JobOf<JobName.NOTIFY_SIGNUP>) { async handleUserSignup({ id, tempPassword }: JobOf<JobName.NOTIFY_SIGNUP>) {
const user = await this.userRepository.get(id, { withDeleted: false }); const user = await this.userRepository.get(id, { withDeleted: false });
@ -182,7 +245,7 @@ export class NotificationService extends BaseService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const { server } = await this.getConfig({ withCache: true }); const { server, templates } = await this.getConfig({ withCache: true });
const { port } = this.configRepository.getEnv(); const { port } = this.configRepository.getEnv();
const { html, text } = await this.notificationRepository.renderEmail({ const { html, text } = await this.notificationRepository.renderEmail({
template: EmailTemplate.WELCOME, template: EmailTemplate.WELCOME,
@ -192,6 +255,7 @@ export class NotificationService extends BaseService {
username: user.email, username: user.email,
password: tempPassword, password: tempPassword,
}, },
customTemplate: templates.email.welcomeTemplate,
}); });
await this.jobRepository.queue({ await this.jobRepository.queue({
@ -227,7 +291,7 @@ export class NotificationService extends BaseService {
const attachment = await this.getAlbumThumbnailAttachment(album); 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 { port } = this.configRepository.getEnv();
const { html, text } = await this.notificationRepository.renderEmail({ const { html, text } = await this.notificationRepository.renderEmail({
template: EmailTemplate.ALBUM_INVITE, template: EmailTemplate.ALBUM_INVITE,
@ -239,6 +303,7 @@ export class NotificationService extends BaseService {
recipientName: recipient.name, recipientName: recipient.name,
cid: attachment ? attachment.cid : undefined, cid: attachment ? attachment.cid : undefined,
}, },
customTemplate: templates.email.albumInviteTemplate,
}); });
await this.jobRepository.queue({ await this.jobRepository.queue({
@ -273,7 +338,7 @@ export class NotificationService extends BaseService {
); );
const attachment = await this.getAlbumThumbnailAttachment(album); 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 { port } = this.configRepository.getEnv();
for (const recipient of recipients) { for (const recipient of recipients) {
@ -297,6 +362,7 @@ export class NotificationService extends BaseService {
recipientName: recipient.name, recipientName: recipient.name,
cid: attachment ? attachment.cid : undefined, cid: attachment ? attachment.cid : undefined,
}, },
customTemplate: templates.email.albumUpdateTemplate,
}); });
await this.jobRepository.queue({ await this.jobRepository.queue({

View File

@ -190,6 +190,13 @@ const updatedConfig = Object.freeze<SystemConfig>({
}, },
}, },
}, },
templates: {
email: {
albumInviteTemplate: '',
welcomeTemplate: '',
albumUpdateTemplate: '',
},
},
}); });
describe(SystemConfigService.name, () => { 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", "justified-layout": "^4.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"socket.io-client": "^4.7.5", "socket.io-client": "~4.7.5",
"svelte-gestures": "^5.0.4", "svelte-gestures": "^5.0.4",
"svelte-i18n": "^4.0.1", "svelte-i18n": "^4.0.1",
"svelte-local-storage-store": "^0.6.4", "svelte-local-storage-store": "^0.6.4",

View File

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