diff --git a/docs/static/img/ios-app-store-badge.png b/docs/static/img/ios-app-store-badge.png
index df5df80ee..817c13af0 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 914d291e1..a2acbc12f 100644
Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 4d7a4a533..2591de491 100644
Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ
diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart
new file mode 100644
index 000000000..a3506b9bc
Binary files /dev/null and b/mobile/openapi/lib/api/notifications_api.dart differ
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index c53f30527..ed3f1ce40 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 d294110be..3929afc0b 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 221e382cf..ba52f9d7b 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 000000000..cc07022a9
--- /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 b593c0edb..33166215a 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 000000000..d419cddf9
--- /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) => (
+
+
+
+
+
+
+
+ Hey {displayName} , this is the test email from your Immich Instance
+
+
+
+
+ {baseUrl}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 d34173915..c0ba4e209 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 13f9a46ba..ef6c8c2f3 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 8efc6a6c3..dab9dd91b 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 d18926296..bf7e336e5 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}
/>
+
+
+ {$t('admin.notification_email_sent_test_email_button')}
+
+ {#if isSending}
+
+ {/if}
+
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json
index 36eec57f1..5cdd5086c 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",