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:
parent
4bf1b84cc2
commit
292182fa7f
@ -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" />
|
||||
|
BIN
docs/docs/administration/img/user-notifications-templates.png
Normal file
BIN
docs/docs/administration/img/user-notifications-templates.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 195 KiB |
@ -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
|
||||
|
12
i18n/en.json
12
i18n/en.json
@ -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"
|
||||
}
|
||||
}
|
10
i18n/nl.json
10
i18n/nl.json
@ -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
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/notifications_api.dart
generated
BIN
mobile/openapi/lib/api/notifications_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_template_emails_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/system_config_template_emails_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_templates_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/system_config_templates_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/template_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/template_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/template_response_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/template_response_dto.dart
generated
Normal file
Binary file not shown.
@ -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": {
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
|
@ -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 = {
|
||||
|
@ -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);
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -190,6 +190,13 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
},
|
||||
},
|
||||
},
|
||||
templates: {
|
||||
email: {
|
||||
albumInviteTemplate: '',
|
||||
welcomeTemplate: '',
|
||||
albumUpdateTemplate: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe(SystemConfigService.name, () => {
|
||||
|
5
server/src/utils/replace-template-tags.ts
Normal file
5
server/src/utils/replace-template-tags.ts
Normal 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
2
web/package-lock.json
generated
@ -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",
|
||||
|
@ -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>
|
||||
|
@ -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>
|
Loading…
Reference in New Issue
Block a user