diff --git a/docs/static/img/ios-app-store-badge.png b/docs/static/img/ios-app-store-badge.png index df5df80eeec5a..817c13af0d88f 100644 Binary files a/docs/static/img/ios-app-store-badge.png and b/docs/static/img/ios-app-store-badge.png differ diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 914d291e1ea58..a2acbc12f6150 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -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 | diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 4d7a4a533d14c..2591de491a3dd 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -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'; diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart new file mode 100644 index 0000000000000..a3506b9bc1a7f --- /dev/null +++ b/mobile/openapi/lib/api/notifications_api.dart @@ -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 sendTestEmailWithHttpInfo(SystemConfigSmtpDto systemConfigSmtpDto,) async { + // ignore: prefer_const_declarations + final path = r'/notifications/test-email'; + + // ignore: prefer_final_locals + Object? postBody = systemConfigSmtpDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): + Future sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async { + final response = await sendTestEmailWithHttpInfo(systemConfigSmtpDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c53f30527c50d..ed3f1ce40311a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d294110be1e06..3929afc0bb8fa 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -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) { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 221e382cfead1..ba52f9d7b9865 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -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, diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts new file mode 100644 index 0000000000000..cc07022a93747 --- /dev/null +++ b/server/src/controllers/notification.controller.ts @@ -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); + } +} diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index b593c0edbf341..33166215a0e79 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -394,7 +394,7 @@ class SystemConfigSmtpTransportDto { password!: string; } -class SystemConfigSmtpDto { +export class SystemConfigSmtpDto { @IsBoolean() enabled!: boolean; diff --git a/server/src/emails/test.email.tsx b/server/src/emails/test.email.tsx new file mode 100644 index 0000000000000..d419cddf995b3 --- /dev/null +++ b/server/src/emails/test.email.tsx @@ -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) => ( + + + This is a test email from Immich + + +
+ Immich + + + Hey {displayName}, this is the test email from your Immich Instance + + + + + {baseUrl} + + +
+ +
+ +
+ + + + Immich + + + Immich + + + +
+ + + Immich project is available under GNU AGPL v3 license. + +
+ + +); + +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', +}; diff --git a/server/src/interfaces/notification.interface.ts b/server/src/interfaces/notification.interface.ts index d34173915ccac..c0ba4e209d052 100644 --- a/server/src/interfaces/notification.interface.ts +++ b/server/src/interfaces/notification.interface.ts @@ -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; diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts index 13f9a46badf7e..ef6c8c2f39603 100644 --- a/server/src/repositories/notification.repository.ts +++ b/server/src/repositories/notification.repository.ts @@ -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 { 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, }); } } diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 8efc6a6c33872..dab9dd91b33c9 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -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) { diff --git a/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte b/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte index d189262960056..bf7e336e52a68 100644 --- a/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte +++ b/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte @@ -1,5 +1,5 @@
@@ -93,6 +137,15 @@ bind:value={config.notifications.smtp.from} isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from} /> + +
+ + {#if isSending} + + {/if} +
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 36eec57f17656..5cdd5086caff2 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -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",