mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 22:51:59 +00: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
15 changed files with 321 additions and 17 deletions
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 |
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
Normal file
BIN
mobile/openapi/lib/api/notifications_api.dart
generated
Normal file
Binary file not shown.
|
@ -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 a new issue