feat(web): send test email button (#10011)

* feat(web): test email button

* openapi

* UI button

* Show notification

* pr feedback

* remove jobs

* send email directly from repository and add feedback

* avoid sending many emails

* linter

* pr feedback

* lint

* lint

* lint
This commit is contained in:
Alex 2024-06-07 11:34:09 -05:00 committed by GitHub
parent d5f3d98dfc
commit 9ac2ac2fcb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 380 additions and 17 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -144,6 +144,7 @@ Class | Method | HTTP request | Description
*MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets | *MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets |
*MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories | *MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories |
*MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} | *MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} |
*NotificationsApi* | [**sendTestEmail**](doc//NotificationsApi.md#sendtestemail) | **POST** /notifications/test-email |
*OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback | *OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback |
*OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link | *OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link |
*OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect | *OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect |

View File

@ -43,6 +43,7 @@ part 'api/jobs_api.dart';
part 'api/libraries_api.dart'; part 'api/libraries_api.dart';
part 'api/map_api.dart'; part 'api/map_api.dart';
part 'api/memories_api.dart'; part 'api/memories_api.dart';
part 'api/notifications_api.dart';
part 'api/o_auth_api.dart'; part 'api/o_auth_api.dart';
part 'api/partners_api.dart'; part 'api/partners_api.dart';
part 'api/people_api.dart'; part 'api/people_api.dart';

View File

@ -0,0 +1,57 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class NotificationsApi {
NotificationsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'POST /notifications/test-email' operation and returns the [Response].
/// Parameters:
///
/// * [SystemConfigSmtpDto] systemConfigSmtpDto (required):
Future<Response> sendTestEmailWithHttpInfo(SystemConfigSmtpDto systemConfigSmtpDto,) async {
// ignore: prefer_const_declarations
final path = r'/notifications/test-email';
// ignore: prefer_final_locals
Object? postBody = systemConfigSmtpDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [SystemConfigSmtpDto] systemConfigSmtpDto (required):
Future<void> sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async {
final response = await sendTestEmailWithHttpInfo(systemConfigSmtpDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

View File

@ -3466,6 +3466,41 @@
] ]
} }
}, },
"/notifications/test-email": {
"post": {
"operationId": "sendTestEmail",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigSmtpDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications"
]
}
},
"/oauth/authorize": { "/oauth/authorize": {
"post": { "post": {
"operationId": "startOAuth", "operationId": "startOAuth",

View File

@ -554,6 +554,19 @@ export type MemoryUpdateDto = {
memoryAt?: string; memoryAt?: string;
seenAt?: string; seenAt?: string;
}; };
export type SystemConfigSmtpTransportDto = {
host: string;
ignoreCert: boolean;
password: string;
port: number;
username: string;
};
export type SystemConfigSmtpDto = {
enabled: boolean;
"from": string;
replyTo: string;
transport: SystemConfigSmtpTransportDto;
};
export type OAuthConfigDto = { export type OAuthConfigDto = {
redirectUri: string; redirectUri: string;
}; };
@ -990,19 +1003,6 @@ export type SystemConfigMapDto = {
export type SystemConfigNewVersionCheckDto = { export type SystemConfigNewVersionCheckDto = {
enabled: boolean; enabled: boolean;
}; };
export type SystemConfigSmtpTransportDto = {
host: string;
ignoreCert: boolean;
password: string;
port: number;
username: string;
};
export type SystemConfigSmtpDto = {
enabled: boolean;
"from": string;
replyTo: string;
transport: SystemConfigSmtpTransportDto;
};
export type SystemConfigNotificationsDto = { export type SystemConfigNotificationsDto = {
smtp: SystemConfigSmtpDto; smtp: SystemConfigSmtpDto;
}; };
@ -2022,6 +2022,15 @@ export function addMemoryAssets({ id, bulkIdsDto }: {
body: bulkIdsDto body: bulkIdsDto
}))); })));
} }
export function sendTestEmail({ systemConfigSmtpDto }: {
systemConfigSmtpDto: SystemConfigSmtpDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/notifications/test-email", oazapfts.json({
...opts,
method: "POST",
body: systemConfigSmtpDto
})));
}
export function startOAuth({ oAuthConfigDto }: { export function startOAuth({ oAuthConfigDto }: {
oAuthConfigDto: OAuthConfigDto; oAuthConfigDto: OAuthConfigDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {

View File

@ -14,6 +14,7 @@ import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller'; import { LibraryController } from 'src/controllers/library.controller';
import { MapController } from 'src/controllers/map.controller'; import { MapController } from 'src/controllers/map.controller';
import { MemoryController } from 'src/controllers/memory.controller'; import { MemoryController } from 'src/controllers/memory.controller';
import { NotificationController } from 'src/controllers/notification.controller';
import { OAuthController } from 'src/controllers/oauth.controller'; import { OAuthController } from 'src/controllers/oauth.controller';
import { PartnerController } from 'src/controllers/partner.controller'; import { PartnerController } from 'src/controllers/partner.controller';
import { PersonController } from 'src/controllers/person.controller'; import { PersonController } from 'src/controllers/person.controller';
@ -46,6 +47,7 @@ export const controllers = [
LibraryController, LibraryController,
MapController, MapController,
MemoryController, MemoryController,
NotificationController,
OAuthController, OAuthController,
PartnerController, PartnerController,
PersonController, PersonController,

View File

@ -0,0 +1,19 @@
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { NotificationService } from 'src/services/notification.service';
@ApiTags('Notifications')
@Controller('notifications')
export class NotificationController {
constructor(private service: NotificationService) {}
@Post('test-email')
@HttpCode(200)
@Authenticated({ admin: true })
sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto) {
return this.service.sendTestEmail(auth.user.id, dto);
}
}

View File

@ -394,7 +394,7 @@ class SystemConfigSmtpTransportDto {
password!: string; password!: string;
} }
class SystemConfigSmtpDto { export class SystemConfigSmtpDto {
@IsBoolean() @IsBoolean()
enabled!: boolean; enabled!: boolean;

View File

@ -0,0 +1,134 @@
import {
Body,
Button,
Column,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components';
import * as CSS from 'csstype';
import * as React from 'react';
import { TestEmailProps } from 'src/interfaces/notification.interface';
export const TestEmail = ({ baseUrl, displayName }: TestEmailProps) => (
<Html>
<Head />
<Preview>This is a test email from Immich</Preview>
<Body
style={{
margin: 0,
padding: 0,
backgroundColor: '#ffffff',
color: 'rgb(66, 80, 175)',
fontFamily: 'Overpass, sans-serif',
fontSize: '18px',
lineHeight: '24px',
}}
>
<Container
style={{
width: '480px',
maxWidth: '100%',
padding: '10px',
margin: '0 auto',
}}
>
<Section
style={{
padding: '36px',
tableLayout: 'fixed',
backgroundColor: 'rgb(226, 232, 240)',
border: 'solid 0px rgb(248 113 113)',
borderRadius: '50px',
textAlign: 'center' as const,
}}
>
<Img
src="https://immich.app/img/immich-logo-inline-light.png"
alt="Immich"
style={{
height: 'auto',
margin: '0 auto 48px auto',
width: '50%',
alignSelf: 'center',
color: 'white',
}}
/>
<Text style={text}>
Hey <strong>{displayName}</strong>, this is the test email from your Immich Instance
</Text>
<Row>
<Link style={{ marginTop: '50px' }} href={baseUrl}>
{baseUrl}
</Link>
</Row>
</Section>
<Hr style={{ color: 'rgb(66, 80, 175)', marginTop: '24px' }} />
<Section style={{ textAlign: 'center' }}>
<Row>
<Column align="center">
<Link href="https://play.google.com/store/apps/details?id=app.alextran.immich">
<Img src={`https://immich.app/img/google-play-badge.png`} height="96px" alt="Immich" />
</Link>
<Link href="https://apps.apple.com/sg/app/immich/id1613945652">
<Img
src={`https://immich.app/img/ios-app-store-badge.png`}
alt="Immich"
style={{ height: '72px', padding: '14px' }}
/>
</Link>
</Column>
</Row>
</Section>
<Text
style={{
color: '#6a737d',
fontSize: '0.8rem',
textAlign: 'center' as const,
marginTop: '12px',
}}
>
<Link href="https://immich.app">Immich</Link> project is available under GNU AGPL v3 license.
</Text>
</Container>
</Body>
</Html>
);
TestEmail.PreviewProps = {
baseUrl: 'https://demo.immich.app/auth/login',
displayName: 'Alan Turing',
} as TestEmailProps;
export default TestEmail;
const text = {
margin: '0 0 24px 0',
textAlign: 'left' as const,
fontSize: '18px',
lineHeight: '24px',
};
const button: CSS.Properties = {
backgroundColor: 'rgb(66, 80, 175)',
margin: '1em 0',
padding: '0.75em 3em',
color: '#fff',
fontSize: '1em',
fontWeight: 700,
lineHeight: 1.5,
textTransform: 'uppercase',
borderRadius: '9999px',
};

View File

@ -26,6 +26,8 @@ export type SmtpOptions = {
}; };
export enum EmailTemplate { export enum EmailTemplate {
TEST_EMAIL = 'test',
// AUTH // AUTH
WELCOME = 'welcome', WELCOME = 'welcome',
RESET_PASSWORD = 'reset-password', RESET_PASSWORD = 'reset-password',
@ -39,6 +41,10 @@ interface BaseEmailProps {
baseUrl: string; baseUrl: string;
} }
export interface TestEmailProps extends BaseEmailProps {
displayName: string;
}
export interface WelcomeEmailProps extends BaseEmailProps { export interface WelcomeEmailProps extends BaseEmailProps {
displayName: string; displayName: string;
username: string; username: string;
@ -61,6 +67,10 @@ export interface AlbumUpdateEmailProps extends BaseEmailProps {
} }
export type EmailRenderRequest = export type EmailRenderRequest =
| {
template: EmailTemplate.TEST_EMAIL;
data: TestEmailProps;
}
| { | {
template: EmailTemplate.WELCOME; template: EmailTemplate.WELCOME;
data: WelcomeEmailProps; data: WelcomeEmailProps;

View File

@ -4,6 +4,7 @@ import { createTransport } from 'nodemailer';
import React from 'react'; import React from 'react';
import { AlbumInviteEmail } from 'src/emails/album-invite.email'; import { AlbumInviteEmail } from 'src/emails/album-invite.email';
import { AlbumUpdateEmail } from 'src/emails/album-update.email'; import { AlbumUpdateEmail } from 'src/emails/album-update.email';
import { TestEmail } from 'src/emails/test.email';
import { WelcomeEmail } from 'src/emails/welcome.email'; import { WelcomeEmail } from 'src/emails/welcome.email';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { import {
@ -58,6 +59,10 @@ export class NotificationRepository implements INotificationRepository {
private render({ template, data }: EmailRenderRequest): React.FunctionComponentElement<any> { private render({ template, data }: EmailRenderRequest): React.FunctionComponentElement<any> {
switch (template) { switch (template) {
case EmailTemplate.TEST_EMAIL: {
return React.createElement(TestEmail, data);
}
case EmailTemplate.WELCOME: { case EmailTemplate.WELCOME: {
return React.createElement(WelcomeEmail, data); return React.createElement(WelcomeEmail, data);
} }
@ -84,6 +89,7 @@ export class NotificationRepository implements INotificationRepository {
pass: options.password, pass: options.password,
} }
: undefined, : undefined,
connectionTimeout: 5000,
}); });
} }
} }

View File

@ -1,7 +1,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnServerEvent } from 'src/decorators'; import { OnServerEvent } from 'src/decorators';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
@ -55,6 +56,38 @@ export class NotificationService {
} }
} }
async sendTestEmail(id: string, dto: SystemConfigSmtpDto) {
const user = await this.userRepository.get(id, { withDeleted: false });
if (!user) {
throw new Error('User not found');
}
try {
await this.notificationRepository.verifySmtp(dto.transport);
} catch (error) {
throw new HttpException('Failed to verify SMTP configuration', HttpStatus.BAD_REQUEST, { cause: error });
}
const { server } = await this.configCore.getConfig();
const { html, text } = this.notificationRepository.renderEmail({
template: EmailTemplate.TEST_EMAIL,
data: {
baseUrl: server.externalDomain || DEFAULT_EXTERNAL_DOMAIN,
displayName: user.name,
},
});
await this.notificationRepository.sendEmail({
to: user.email,
subject: 'Test email from Immich',
html,
text,
from: dto.from,
replyTo: dto.replyTo || dto.from,
smtp: dto.transport,
});
}
async handleUserSignup({ id, tempPassword }: INotifySignupJob) { async handleUserSignup({ id, tempPassword }: INotifySignupJob) {
const user = await this.userRepository.get(id, { withDeleted: false }); const user = await this.userRepository.get(id, { withDeleted: false });
if (!user) { if (!user) {

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { SystemConfigDto } from '@immich/sdk'; import { sendTestEmail, type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@ -11,13 +11,57 @@
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import Button from '$lib/components/elements/buttons/button.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { user } from '$lib/stores/user.store';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { handleError } from '$lib/utils/handle-error';
export let savedConfig: SystemConfigDto; export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited export let config: SystemConfigDto; // this is the config that is being edited
export let disabled = false; export let disabled = false;
let isSending = false;
const dispatch = createEventDispatcher<SettingsEventType>(); const dispatch = createEventDispatcher<SettingsEventType>();
const handleSendTestEmail = async () => {
if (isSending) {
return;
}
isSending = true;
try {
await sendTestEmail({
systemConfigSmtpDto: {
enabled: config.notifications.smtp.enabled,
transport: {
host: config.notifications.smtp.transport.host,
port: config.notifications.smtp.transport.port,
username: config.notifications.smtp.transport.username,
password: config.notifications.smtp.transport.password,
ignoreCert: config.notifications.smtp.transport.ignoreCert,
},
from: config.notifications.smtp.from,
replyTo: config.notifications.smtp.from,
},
});
notificationController.show({
type: NotificationType.Info,
message: $t('admin.notification_email_test_email_sent', { values: { email: $user.email } }),
});
dispatch('save', { notifications: config.notifications });
} catch (error) {
handleError(error, $t('admin.notification_email_test_email_failed'));
} finally {
isSending = false;
}
};
</script> </script>
<div> <div>
@ -93,6 +137,15 @@
bind:value={config.notifications.smtp.from} bind:value={config.notifications.smtp.from}
isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from} isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from}
/> />
<div class="flex gap-2 place-items-center">
<Button size="sm" disabled={disabled || !config.notifications.smtp.enabled} on:click={handleSendTestEmail}
>{$t('admin.notification_email_sent_test_email_button')}
</Button>
{#if isSending}
<LoadingSpinner />
{/if}
</div>
</div> </div>
</SettingAccordion> </SettingAccordion>
</div> </div>

View File

@ -102,6 +102,9 @@
"notification_email_password_description": "Password to use when authenticating with the email server", "notification_email_password_description": "Password to use when authenticating with the email server",
"notification_email_port_description": "Port of the email server (e.g 25, 465, or 587)", "notification_email_port_description": "Port of the email server (e.g 25, 465, or 587)",
"notification_email_setting_description": "Settings for sending email notifications", "notification_email_setting_description": "Settings for sending email notifications",
"notification_email_test_email_failed": "Failed to send test email, check your values",
"notification_email_test_email_sent": "A test email has been sent to {email}. Please check your inbox.",
"notification_email_sent_test_email_button": "Send test email and save",
"notification_email_username_description": "Username to use when authenticating with the email server", "notification_email_username_description": "Username to use when authenticating with the email server",
"notification_enable_email_notifications": "Enable email notifications", "notification_enable_email_notifications": "Enable email notifications",
"notification_settings": "Notification Settings", "notification_settings": "Notification Settings",