mirror of https://github.com/immich-app/immich.git
feat(server): email notifications (#8447)
* feat(server): add `react-mail` as mail template engine and `nodemailer` * feat(server): add `smtp` related configs to `SystemConfig` * feat(web): add page for SMTP settings * feat(server): add `react-email.adapter` This adapter render the React-Email into HTML and plain/text email. The output is set as the body of the email. * feat(server): add `MailRepository` and `MailService` Allow to use the NestJS-modules-mailer module to send SMTP emails. This is the base transport for the `NotificationRepository` * feat(server): register the job dispatcher and Job for async email This allows to queue email sending jobs for the `EmailService`. * feat(server): add `NotificationRepository` and `NotificationService` This act as a middleware to properly route the notification to the right transport. As POC I've only implemented a simple SMTP transport. * feat(server): add `welcome` email template * feat(server): add the first notification on `createUser` in `UserService` This trigger an event for the `NotificationRepository` that once processes by using the global config and per-user config will carry the payload to the right notification transport. * chore: clean up * chore: clean up web * fix: type errors" * fix package lock * fix mail sending, option to ignore certs * chore: open api * chore: clean up * remove unused import * feat: email feature flag * chore: remove unused interface * small styling --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Daniel Dietzler <mail@ddietzler.dev> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>pull/7107/head
parent
4b86c7a298
commit
9bce3417e9
@ -0,0 +1,15 @@
|
||||
# openapi.model.SystemConfigNotificationsDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**smtp** | [**SystemConfigSmtpDto**](SystemConfigSmtpDto.md) | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
# openapi.model.SystemConfigSmtpDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**enabled** | **bool** | |
|
||||
**from** | **String** | |
|
||||
**replyTo** | **String** | |
|
||||
**transport** | [**SystemConfigSmtpTransportDto**](SystemConfigSmtpTransportDto.md) | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
# openapi.model.SystemConfigSmtpTransportDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**host** | **String** | |
|
||||
**ignoreCert** | **bool** | |
|
||||
**password** | **String** | |
|
||||
**port** | **num** | |
|
||||
**username** | **String** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// 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 SystemConfigNotificationsDto {
|
||||
/// Returns a new [SystemConfigNotificationsDto] instance.
|
||||
SystemConfigNotificationsDto({
|
||||
required this.smtp,
|
||||
});
|
||||
|
||||
SystemConfigSmtpDto smtp;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigNotificationsDto &&
|
||||
other.smtp == smtp;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(smtp.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigNotificationsDto[smtp=$smtp]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'smtp'] = this.smtp;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SystemConfigNotificationsDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SystemConfigNotificationsDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SystemConfigNotificationsDto(
|
||||
smtp: SystemConfigSmtpDto.fromJson(json[r'smtp'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SystemConfigNotificationsDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SystemConfigNotificationsDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SystemConfigNotificationsDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SystemConfigNotificationsDto> mapFromJson(dynamic json) {
|
||||
final map = <String, SystemConfigNotificationsDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SystemConfigNotificationsDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SystemConfigNotificationsDto-objects as value to a dart map
|
||||
static Map<String, List<SystemConfigNotificationsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SystemConfigNotificationsDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SystemConfigNotificationsDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'smtp',
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,122 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// 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 SystemConfigSmtpDto {
|
||||
/// Returns a new [SystemConfigSmtpDto] instance.
|
||||
SystemConfigSmtpDto({
|
||||
required this.enabled,
|
||||
required this.from,
|
||||
required this.replyTo,
|
||||
required this.transport,
|
||||
});
|
||||
|
||||
bool enabled;
|
||||
|
||||
String from;
|
||||
|
||||
String replyTo;
|
||||
|
||||
SystemConfigSmtpTransportDto transport;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigSmtpDto &&
|
||||
other.enabled == enabled &&
|
||||
other.from == from &&
|
||||
other.replyTo == replyTo &&
|
||||
other.transport == transport;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode) +
|
||||
(from.hashCode) +
|
||||
(replyTo.hashCode) +
|
||||
(transport.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigSmtpDto[enabled=$enabled, from=$from, replyTo=$replyTo, transport=$transport]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'from'] = this.from;
|
||||
json[r'replyTo'] = this.replyTo;
|
||||
json[r'transport'] = this.transport;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SystemConfigSmtpDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SystemConfigSmtpDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SystemConfigSmtpDto(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
from: mapValueOfType<String>(json, r'from')!,
|
||||
replyTo: mapValueOfType<String>(json, r'replyTo')!,
|
||||
transport: SystemConfigSmtpTransportDto.fromJson(json[r'transport'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SystemConfigSmtpDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SystemConfigSmtpDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SystemConfigSmtpDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SystemConfigSmtpDto> mapFromJson(dynamic json) {
|
||||
final map = <String, SystemConfigSmtpDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SystemConfigSmtpDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SystemConfigSmtpDto-objects as value to a dart map
|
||||
static Map<String, List<SystemConfigSmtpDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SystemConfigSmtpDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SystemConfigSmtpDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'enabled',
|
||||
'from',
|
||||
'replyTo',
|
||||
'transport',
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// 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 SystemConfigSmtpTransportDto {
|
||||
/// Returns a new [SystemConfigSmtpTransportDto] instance.
|
||||
SystemConfigSmtpTransportDto({
|
||||
required this.host,
|
||||
required this.ignoreCert,
|
||||
required this.password,
|
||||
required this.port,
|
||||
required this.username,
|
||||
});
|
||||
|
||||
String host;
|
||||
|
||||
bool ignoreCert;
|
||||
|
||||
String password;
|
||||
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 65535
|
||||
num port;
|
||||
|
||||
String username;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigSmtpTransportDto &&
|
||||
other.host == host &&
|
||||
other.ignoreCert == ignoreCert &&
|
||||
other.password == password &&
|
||||
other.port == port &&
|
||||
other.username == username;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(host.hashCode) +
|
||||
(ignoreCert.hashCode) +
|
||||
(password.hashCode) +
|
||||
(port.hashCode) +
|
||||
(username.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigSmtpTransportDto[host=$host, ignoreCert=$ignoreCert, password=$password, port=$port, username=$username]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'host'] = this.host;
|
||||
json[r'ignoreCert'] = this.ignoreCert;
|
||||
json[r'password'] = this.password;
|
||||
json[r'port'] = this.port;
|
||||
json[r'username'] = this.username;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SystemConfigSmtpTransportDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SystemConfigSmtpTransportDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SystemConfigSmtpTransportDto(
|
||||
host: mapValueOfType<String>(json, r'host')!,
|
||||
ignoreCert: mapValueOfType<bool>(json, r'ignoreCert')!,
|
||||
password: mapValueOfType<String>(json, r'password')!,
|
||||
port: num.parse('${json[r'port']}'),
|
||||
username: mapValueOfType<String>(json, r'username')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SystemConfigSmtpTransportDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SystemConfigSmtpTransportDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SystemConfigSmtpTransportDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SystemConfigSmtpTransportDto> mapFromJson(dynamic json) {
|
||||
final map = <String, SystemConfigSmtpTransportDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SystemConfigSmtpTransportDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SystemConfigSmtpTransportDto-objects as value to a dart map
|
||||
static Map<String, List<SystemConfigSmtpTransportDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SystemConfigSmtpTransportDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SystemConfigSmtpTransportDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'host',
|
||||
'ignoreCert',
|
||||
'password',
|
||||
'port',
|
||||
'username',
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// 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
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for SystemConfigNotificationsDto
|
||||
void main() {
|
||||
// final instance = SystemConfigNotificationsDto();
|
||||
|
||||
group('test SystemConfigNotificationsDto', () {
|
||||
// SystemConfigSmtpDto smtp
|
||||
test('to test the property `smtp`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// 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
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for SystemConfigSmtpDto
|
||||
void main() {
|
||||
// final instance = SystemConfigSmtpDto();
|
||||
|
||||
group('test SystemConfigSmtpDto', () {
|
||||
// bool enabled
|
||||
test('to test the property `enabled`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String from
|
||||
test('to test the property `from`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String replyTo
|
||||
test('to test the property `replyTo`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// SystemConfigSmtpTransportDto transport
|
||||
test('to test the property `transport`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// 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
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for SystemConfigSmtpTransportDto
|
||||
void main() {
|
||||
// final instance = SystemConfigSmtpTransportDto();
|
||||
|
||||
group('test SystemConfigSmtpTransportDto', () {
|
||||
// String host
|
||||
test('to test the property `host`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool ignoreCert
|
||||
test('to test the property `ignoreCert`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String password
|
||||
test('to test the property `password`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// num port
|
||||
test('to test the property `port`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String username
|
||||
test('to test the property `username`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,159 @@
|
||||
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 { WelcomeEmailProps } from 'src/interfaces/notification.interface';
|
||||
|
||||
export const WelcomeEmail = ({ baseUrl, displayName, username, password }: WelcomeEmailProps) => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>You have been invited to a new Immich instance.</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>!
|
||||
</Text>
|
||||
|
||||
<Text style={text}>A new account has been created for you.</Text>
|
||||
|
||||
<Text style={text}>
|
||||
<strong>Username</strong>: {username}
|
||||
{password && (
|
||||
<>
|
||||
<br />
|
||||
<strong>Password</strong>: {password}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Row>
|
||||
<Text style={{ ...text, marginBottom: '36px' }}>
|
||||
To login, open the link in a browser, or click the button below.
|
||||
</Text>
|
||||
</Row>
|
||||
<Row>
|
||||
<Link style={{ marginTop: '50px' }} href={baseUrl}>
|
||||
{baseUrl}
|
||||
</Link>
|
||||
</Row>
|
||||
<Row>
|
||||
<Button style={button} href={`${baseUrl}/auth/login`}>
|
||||
Login
|
||||
</Button>
|
||||
</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
|
||||
// TODO get this as a png
|
||||
src={`https://immich.app/img/ios-app-store-badge.svg`}
|
||||
alt="Immich"
|
||||
style={{ height: '68px', 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>
|
||||
);
|
||||
|
||||
WelcomeEmail.PreviewProps = {
|
||||
baseUrl: 'https://demo.immich.app/auth/login',
|
||||
displayName: 'Alan Turing',
|
||||
username: 'alanturing',
|
||||
password: 'mysuperpassword',
|
||||
} as WelcomeEmailProps;
|
||||
|
||||
export default WelcomeEmail;
|
||||
|
||||
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',
|
||||
};
|
||||
@ -0,0 +1,44 @@
|
||||
export const INotificationRepository = 'INotificationRepository';
|
||||
|
||||
export type SendEmailOptions = {
|
||||
from: string;
|
||||
to: string;
|
||||
replyTo?: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
smtp: SmtpOptions;
|
||||
};
|
||||
|
||||
export type SmtpOptions = {
|
||||
host: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
ignoreCert?: boolean;
|
||||
};
|
||||
|
||||
export enum EmailTemplate {
|
||||
WELCOME = 'welcome',
|
||||
RESET_PASSWORD = 'reset-password',
|
||||
}
|
||||
|
||||
export interface WelcomeEmailProps {
|
||||
baseUrl: string;
|
||||
displayName: string;
|
||||
username: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export type EmailRenderRequest = { template: EmailTemplate.WELCOME; data: WelcomeEmailProps };
|
||||
|
||||
export type SendEmailResponse = {
|
||||
messageId: string;
|
||||
response: any;
|
||||
};
|
||||
|
||||
export interface INotificationRepository {
|
||||
renderEmail(request: EmailRenderRequest): { html: string; text: string };
|
||||
sendEmail(options: SendEmailOptions): Promise<SendEmailResponse>;
|
||||
verifySmtp(options: SmtpOptions): Promise<true>;
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { render } from '@react-email/render';
|
||||
import { createTransport } from 'nodemailer';
|
||||
import React from 'react';
|
||||
import { WelcomeEmail } from 'src/emails/welcome.email';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import {
|
||||
EmailRenderRequest,
|
||||
EmailTemplate,
|
||||
INotificationRepository,
|
||||
SendEmailOptions,
|
||||
SendEmailResponse,
|
||||
SmtpOptions,
|
||||
} from 'src/interfaces/notification.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class NotificationRepository implements INotificationRepository {
|
||||
constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
|
||||
this.logger.setContext(NotificationRepository.name);
|
||||
}
|
||||
|
||||
verifySmtp(options: SmtpOptions): Promise<true> {
|
||||
const transport = this.createTransport(options);
|
||||
try {
|
||||
return transport.verify();
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
}
|
||||
|
||||
renderEmail(request: EmailRenderRequest): { html: string; text: string } {
|
||||
const component = this.render(request);
|
||||
const html = render(component, { pretty: true });
|
||||
const text = render(component, { plainText: true });
|
||||
return { html, text };
|
||||
}
|
||||
|
||||
sendEmail({ to, from, subject, html, text, smtp }: SendEmailOptions): Promise<SendEmailResponse> {
|
||||
this.logger.debug(`Sending email to ${to} with subject: ${subject}`);
|
||||
const transport = this.createTransport(smtp);
|
||||
try {
|
||||
return transport.sendMail({ to, from, subject, html, text });
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
}
|
||||
|
||||
private render({ template, data }: EmailRenderRequest): React.FunctionComponentElement<any> {
|
||||
switch (template) {
|
||||
case EmailTemplate.WELCOME: {
|
||||
return React.createElement(WelcomeEmail, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createTransport(options: SmtpOptions) {
|
||||
return createTransport({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
tls: { rejectUnauthorized: options.ignoreCert },
|
||||
auth:
|
||||
options.username || options.password
|
||||
? {
|
||||
user: options.username,
|
||||
pass: options.password,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnServerEvent } from 'src/decorators';
|
||||
import { ServerAsyncEvent, ServerAsyncEventMap } from 'src/interfaces/event.interface';
|
||||
import { IEmailJob, IJobRepository, INotifySignupJob, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
|
||||
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
constructor(
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(INotificationRepository) private notificationRepository: INotificationRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
this.logger.setContext(NotificationService.name);
|
||||
this.configCore = SystemConfigCore.create(configRepository, logger);
|
||||
}
|
||||
|
||||
init() {
|
||||
// TODO
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@OnServerEvent(ServerAsyncEvent.CONFIG_VALIDATE)
|
||||
async onValidateConfig({ newConfig }: ServerAsyncEventMap[ServerAsyncEvent.CONFIG_VALIDATE]) {
|
||||
try {
|
||||
if (newConfig.notifications.smtp.enabled) {
|
||||
await this.notificationRepository.verifySmtp(newConfig.notifications.smtp.transport);
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Failed to validate SMTP configuration: ${error}`, error?.stack);
|
||||
throw new Error(`Invalid SMTP configuration: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async handleUserSignup({ id, tempPassword }: INotifySignupJob) {
|
||||
const user = await this.userRepository.get(id, { withDeleted: false });
|
||||
if (!user) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const { server } = await this.configCore.getConfig();
|
||||
const { html, text } = this.notificationRepository.renderEmail({
|
||||
template: EmailTemplate.WELCOME,
|
||||
data: {
|
||||
baseUrl: server.externalDomain || 'http://localhost:2283',
|
||||
displayName: user.name,
|
||||
username: user.email,
|
||||
password: tempPassword,
|
||||
},
|
||||
});
|
||||
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.SEND_EMAIL,
|
||||
data: {
|
||||
to: user.email,
|
||||
subject: 'Welcome to Immich',
|
||||
html,
|
||||
text,
|
||||
},
|
||||
});
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
async handleSendEmail(data: IEmailJob): Promise<JobStatus> {
|
||||
const { notifications } = await this.configCore.getConfig();
|
||||
if (!notifications.smtp.enabled) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const { to, subject, html, text: plain } = data;
|
||||
const response = await this.notificationRepository.sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text: plain,
|
||||
from: notifications.smtp.from,
|
||||
replyTo: notifications.smtp.replyTo || notifications.smtp.from,
|
||||
smtp: notifications.smtp.transport,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
this.logger.log(`Sent mail with id: ${response.messageId} status: ${response.response}`);
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { INotificationRepository } from 'src/interfaces/notification.interface';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
export const newNotificationRepositoryMock = (): Mocked<INotificationRepository> => {
|
||||
return {
|
||||
renderEmail: vitest.fn(),
|
||||
sendEmail: vitest.fn(),
|
||||
verifySmtp: vitest.fn(),
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import type { SystemConfigDto } from '@immich/sdk';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { SettingsEventType } from '../admin-settings';
|
||||
import SettingInputField, {
|
||||
SettingInputFieldType,
|
||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
|
||||
export let savedConfig: SystemConfigDto;
|
||||
export let defaultConfig: SystemConfigDto;
|
||||
export let config: SystemConfigDto; // this is the config that is being edited
|
||||
export let disabled = false;
|
||||
|
||||
const dispatch = createEventDispatcher<SettingsEventType>();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" on:submit|preventDefault class="mt-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<SettingAccordion key="email" title="Email" subtitle="Settings for sending email notifications">
|
||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
id="enable-smtp"
|
||||
title="Enabled"
|
||||
subtitle="Enable email notifications"
|
||||
{disabled}
|
||||
bind:checked={config.notifications.smtp.enabled}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
required
|
||||
label="Host"
|
||||
desc="Host of the email server (e.g. smtp.immich.app)"
|
||||
disabled={disabled || !config.notifications.smtp.enabled}
|
||||
bind:value={config.notifications.smtp.transport.host}
|
||||
isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
required
|
||||
label="Port"
|
||||
desc="Port of the email server (e.g 25, 465, or 587)"
|
||||
disabled={disabled || !config.notifications.smtp.enabled}
|
||||
bind:value={config.notifications.smtp.transport.port}
|
||||
isEdited={config.notifications.smtp.transport.port !== savedConfig.notifications.smtp.transport.port}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="Username"
|
||||
desc="Username to use when authenticating with the email server"
|
||||
disabled={disabled || !config.notifications.smtp.enabled}
|
||||
bind:value={config.notifications.smtp.transport.username}
|
||||
isEdited={config.notifications.smtp.transport.username !==
|
||||
savedConfig.notifications.smtp.transport.username}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.PASSWORD}
|
||||
label="Password"
|
||||
desc="Password to use when authenticating with the email server"
|
||||
disabled={disabled || !config.notifications.smtp.enabled}
|
||||
bind:value={config.notifications.smtp.transport.password}
|
||||
isEdited={config.notifications.smtp.transport.password !==
|
||||
savedConfig.notifications.smtp.transport.password}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
id="enable-ignore-cert"
|
||||
title="Ignore certificate errors"
|
||||
subtitle="Ignore TLS certificate validation errors (not recommended)"
|
||||
disabled={disabled || !config.notifications.smtp.enabled}
|
||||
bind:checked={config.notifications.smtp.transport.ignoreCert}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
required
|
||||
label="From address"
|
||||
desc="Sender email address, for example: "Immich Photo Server <noreply@immich.app>""
|
||||
disabled={disabled || !config.notifications.smtp.enabled}
|
||||
bind:value={config.notifications.smtp.from}
|
||||
isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
</div>
|
||||
|
||||
<SettingButtonsRow
|
||||
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['notifications'] })}
|
||||
on:save={() => dispatch('save', { notifications: config.notifications })}
|
||||
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
|
||||
{disabled}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Reference in New Issue