mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00:00
feat: Notification Email Templates (#13940)
This commit is contained in:
parent
4bf1b84cc2
commit
292182fa7f
32 changed files with 638 additions and 104 deletions
|
@ -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" />
|
||||||
|
|
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)
|
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
|
||||||
|
|
10
i18n/en.json
10
i18n/en.json
|
@ -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",
|
||||||
|
|
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",
|
"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
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": {
|
"/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": {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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,8 +12,20 @@ export const AlbumInviteEmail = ({
|
||||||
senderName,
|
senderName,
|
||||||
albumId,
|
albumId,
|
||||||
cid,
|
cid,
|
||||||
}: AlbumInviteEmailProps) => (
|
customTemplate,
|
||||||
<ImmichLayout preview="You have been added to a shared album.">
|
}: AlbumInviteEmailProps) => {
|
||||||
|
const variables = {
|
||||||
|
albumName,
|
||||||
|
recipientName,
|
||||||
|
senderName,
|
||||||
|
albumId,
|
||||||
|
baseUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
const emailContent = customTemplate ? (
|
||||||
|
replaceTemplateTags(customTemplate, variables)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Text className="m-0">
|
<Text className="m-0">
|
||||||
Hey <strong>{recipientName}</strong>!
|
Hey <strong>{recipientName}</strong>!
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -20,6 +33,18 @@ export const AlbumInviteEmail = ({
|
||||||
<Text>
|
<Text>
|
||||||
{senderName} has added you to the album <strong>{albumName}</strong>.
|
{senderName} has added you to the album <strong>{albumName}</strong>.
|
||||||
</Text>
|
</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 && (
|
{cid && (
|
||||||
<Section className="flex justify-center my-0">
|
<Section className="flex justify-center my-0">
|
||||||
|
@ -44,6 +69,7 @@ export const AlbumInviteEmail = ({
|
||||||
</Text>
|
</Text>
|
||||||
</ImmichLayout>
|
</ImmichLayout>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
AlbumInviteEmail.PreviewProps = {
|
AlbumInviteEmail.PreviewProps = {
|
||||||
baseUrl: 'https://demo.immich.app',
|
baseUrl: 'https://demo.immich.app',
|
||||||
|
|
|
@ -3,9 +3,27 @@ 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,
|
||||||
|
albumName,
|
||||||
|
recipientName,
|
||||||
|
albumId,
|
||||||
|
cid,
|
||||||
|
customTemplate,
|
||||||
|
}: AlbumUpdateEmailProps) => {
|
||||||
|
const usableTemplateVariables = {
|
||||||
|
albumName,
|
||||||
|
recipientName,
|
||||||
|
albumId,
|
||||||
|
baseUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
const emailContent = customTemplate ? (
|
||||||
|
replaceTemplateTags(customTemplate, usableTemplateVariables)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Text className="m-0">
|
<Text className="m-0">
|
||||||
Hey <strong>{recipientName}</strong>!
|
Hey <strong>{recipientName}</strong>!
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -14,6 +32,18 @@ export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, c
|
||||||
New media has been added to <strong>{albumName}</strong>,
|
New media has been added to <strong>{albumName}</strong>,
|
||||||
<br /> check it out!
|
<br /> check it out!
|
||||||
</Text>
|
</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 && (
|
{cid && (
|
||||||
<Section className="flex justify-center my-0">
|
<Section className="flex justify-center my-0">
|
||||||
|
@ -38,12 +68,15 @@ export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, c
|
||||||
</Text>
|
</Text>
|
||||||
</ImmichLayout>
|
</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;
|
||||||
|
|
|
@ -3,9 +3,20 @@ 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 = {
|
||||||
|
displayName,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
baseUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
const emailContent = customTemplate ? (
|
||||||
|
replaceTemplateTags(customTemplate, usableTemplateVariables)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Text className="m-0">
|
<Text className="m-0">
|
||||||
Hey <strong>{displayName}</strong>!
|
Hey <strong>{displayName}</strong>!
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -21,6 +32,20 @@ export const WelcomeEmail = ({ baseUrl, displayName, username, password }: Welco
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!customTemplate && emailContent}
|
||||||
|
|
||||||
<Section className="flex justify-center my-6">
|
<Section className="flex justify-center my-6">
|
||||||
<ImmichButton href={`${baseUrl}/auth/login`}>Login</ImmichButton>
|
<ImmichButton href={`${baseUrl}/auth/login`}>Login</ImmichButton>
|
||||||
|
@ -33,6 +58,7 @@ export const WelcomeEmail = ({ baseUrl, displayName, username, password }: Welco
|
||||||
</Text>
|
</Text>
|
||||||
</ImmichLayout>
|
</ImmichLayout>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
WelcomeEmail.PreviewProps = {
|
WelcomeEmail.PreviewProps = {
|
||||||
baseUrl: 'https://demo.immich.app/auth/login',
|
baseUrl: 'https://demo.immich.app/auth/login',
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -190,6 +190,13 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
templates: {
|
||||||
|
email: {
|
||||||
|
albumInviteTemplate: '',
|
||||||
|
welcomeTemplate: '',
|
||||||
|
albumUpdateTemplate: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(SystemConfigService.name, () => {
|
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",
|
"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",
|
||||||
|
|
|
@ -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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<TemplateSettings {defaultConfig} {config} {savedConfig} {onReset} {onSave} />
|
||||||
|
|
||||||
<SettingButtonsRow
|
<SettingButtonsRow
|
||||||
onReset={(options) => onReset({ ...options, configKeys: ['notifications'] })}
|
onReset={(options) => onReset({ ...options, configKeys: ['notifications', 'templates'] })}
|
||||||
onSave={() => onSave({ notifications: config.notifications })}
|
onSave={() => onSave({ notifications: config.notifications, templates: config.templates })}
|
||||||
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
|
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
|
||||||
{disabled}
|
{disabled}
|
||||||
/>
|
/>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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 a new issue