mirror of
https://github.com/immich-app/immich.git
synced 2024-11-15 09:59:00 -07:00
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:
parent
d5f3d98dfc
commit
9ac2ac2fcb
BIN
docs/static/img/ios-app-store-badge.png
vendored
BIN
docs/static/img/ios-app-store-badge.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 70 KiB |
1
mobile/openapi/README.md
generated
1
mobile/openapi/README.md
generated
@ -144,6 +144,7 @@ Class | Method | HTTP request | Description
|
||||
*MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets |
|
||||
*MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories |
|
||||
*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* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link |
|
||||
*OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect |
|
||||
|
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
@ -43,6 +43,7 @@ part 'api/jobs_api.dart';
|
||||
part 'api/libraries_api.dart';
|
||||
part 'api/map_api.dart';
|
||||
part 'api/memories_api.dart';
|
||||
part 'api/notifications_api.dart';
|
||||
part 'api/o_auth_api.dart';
|
||||
part 'api/partners_api.dart';
|
||||
part 'api/people_api.dart';
|
||||
|
57
mobile/openapi/lib/api/notifications_api.dart
generated
Normal file
57
mobile/openapi/lib/api/notifications_api.dart
generated
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
@ -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": {
|
||||
"post": {
|
||||
"operationId": "startOAuth",
|
||||
|
@ -554,6 +554,19 @@ export type MemoryUpdateDto = {
|
||||
memoryAt?: 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 = {
|
||||
redirectUri: string;
|
||||
};
|
||||
@ -990,19 +1003,6 @@ export type SystemConfigMapDto = {
|
||||
export type SystemConfigNewVersionCheckDto = {
|
||||
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 = {
|
||||
smtp: SystemConfigSmtpDto;
|
||||
};
|
||||
@ -2022,6 +2022,15 @@ export function addMemoryAssets({ id, 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 }: {
|
||||
oAuthConfigDto: OAuthConfigDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
|
@ -14,6 +14,7 @@ import { JobController } from 'src/controllers/job.controller';
|
||||
import { LibraryController } from 'src/controllers/library.controller';
|
||||
import { MapController } from 'src/controllers/map.controller';
|
||||
import { MemoryController } from 'src/controllers/memory.controller';
|
||||
import { NotificationController } from 'src/controllers/notification.controller';
|
||||
import { OAuthController } from 'src/controllers/oauth.controller';
|
||||
import { PartnerController } from 'src/controllers/partner.controller';
|
||||
import { PersonController } from 'src/controllers/person.controller';
|
||||
@ -46,6 +47,7 @@ export const controllers = [
|
||||
LibraryController,
|
||||
MapController,
|
||||
MemoryController,
|
||||
NotificationController,
|
||||
OAuthController,
|
||||
PartnerController,
|
||||
PersonController,
|
||||
|
19
server/src/controllers/notification.controller.ts
Normal file
19
server/src/controllers/notification.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -394,7 +394,7 @@ class SystemConfigSmtpTransportDto {
|
||||
password!: string;
|
||||
}
|
||||
|
||||
class SystemConfigSmtpDto {
|
||||
export class SystemConfigSmtpDto {
|
||||
@IsBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
|
134
server/src/emails/test.email.tsx
Normal file
134
server/src/emails/test.email.tsx
Normal 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',
|
||||
};
|
@ -26,6 +26,8 @@ export type SmtpOptions = {
|
||||
};
|
||||
|
||||
export enum EmailTemplate {
|
||||
TEST_EMAIL = 'test',
|
||||
|
||||
// AUTH
|
||||
WELCOME = 'welcome',
|
||||
RESET_PASSWORD = 'reset-password',
|
||||
@ -39,6 +41,10 @@ interface BaseEmailProps {
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface TestEmailProps extends BaseEmailProps {
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface WelcomeEmailProps extends BaseEmailProps {
|
||||
displayName: string;
|
||||
username: string;
|
||||
@ -61,6 +67,10 @@ export interface AlbumUpdateEmailProps extends BaseEmailProps {
|
||||
}
|
||||
|
||||
export type EmailRenderRequest =
|
||||
| {
|
||||
template: EmailTemplate.TEST_EMAIL;
|
||||
data: TestEmailProps;
|
||||
}
|
||||
| {
|
||||
template: EmailTemplate.WELCOME;
|
||||
data: WelcomeEmailProps;
|
||||
|
@ -4,6 +4,7 @@ import { createTransport } from 'nodemailer';
|
||||
import React from 'react';
|
||||
import { AlbumInviteEmail } from 'src/emails/album-invite.email';
|
||||
import { AlbumUpdateEmail } from 'src/emails/album-update.email';
|
||||
import { TestEmail } from 'src/emails/test.email';
|
||||
import { WelcomeEmail } from 'src/emails/welcome.email';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import {
|
||||
@ -58,6 +59,10 @@ export class NotificationRepository implements INotificationRepository {
|
||||
|
||||
private render({ template, data }: EmailRenderRequest): React.FunctionComponentElement<any> {
|
||||
switch (template) {
|
||||
case EmailTemplate.TEST_EMAIL: {
|
||||
return React.createElement(TestEmail, data);
|
||||
}
|
||||
|
||||
case EmailTemplate.WELCOME: {
|
||||
return React.createElement(WelcomeEmail, data);
|
||||
}
|
||||
@ -84,6 +89,7 @@ export class NotificationRepository implements INotificationRepository {
|
||||
pass: options.password,
|
||||
}
|
||||
: undefined,
|
||||
connectionTimeout: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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 { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnServerEvent } from 'src/decorators';
|
||||
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
|
||||
import { AlbumEntity } from 'src/entities/album.entity';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.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) {
|
||||
const user = await this.userRepository.get(id, { withDeleted: false });
|
||||
if (!user) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { SystemConfigDto } from '@immich/sdk';
|
||||
import { sendTestEmail, type SystemConfigDto } from '@immich/sdk';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
@ -11,13 +11,57 @@
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
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 defaultConfig: SystemConfigDto;
|
||||
export let config: SystemConfigDto; // this is the config that is being edited
|
||||
export let disabled = false;
|
||||
|
||||
let isSending = false;
|
||||
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>
|
||||
|
||||
<div>
|
||||
@ -93,6 +137,15 @@
|
||||
bind:value={config.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>
|
||||
</SettingAccordion>
|
||||
</div>
|
||||
|
@ -102,6 +102,9 @@
|
||||
"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_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_enable_email_notifications": "Enable email notifications",
|
||||
"notification_settings": "Notification Settings",
|
||||
|
Loading…
Reference in New Issue
Block a user