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

View file

@ -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,

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;
}
class SystemConfigSmtpDto {
export class SystemConfigSmtpDto {
@IsBoolean()
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 {
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;

View file

@ -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,
});
}
}

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 { 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) {