mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat: other email notifications for approved/available
also adds UI to configure email notifications to frontend
This commit is contained in:
@@ -770,6 +770,36 @@ components:
|
||||
properties:
|
||||
webhookUrl:
|
||||
type: string
|
||||
NotificationEmailSettings:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: false
|
||||
types:
|
||||
type: number
|
||||
example: 2
|
||||
options:
|
||||
type: object
|
||||
properties:
|
||||
emailFrom:
|
||||
type: string
|
||||
example: no-reply@example.com
|
||||
smtpHost:
|
||||
type: string
|
||||
example: 127.0.0.1
|
||||
smtpPort:
|
||||
type: number
|
||||
example: 465
|
||||
secure:
|
||||
type: boolean
|
||||
example: false
|
||||
authUser:
|
||||
type: string
|
||||
nullable: true
|
||||
authPass:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
securitySchemes:
|
||||
cookieAuth:
|
||||
@@ -1225,6 +1255,37 @@ paths:
|
||||
nextExecutionTime:
|
||||
type: string
|
||||
example: '2020-09-02T05:02:23.000Z'
|
||||
/settings/notifications/email:
|
||||
get:
|
||||
summary: Return current email notification settings
|
||||
description: Returns current email notification settings in JSON format
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: Returned email settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NotificationEmailSettings'
|
||||
post:
|
||||
summary: Update email notification settings
|
||||
description: Update current email notification settings with provided values
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NotificationEmailSettings'
|
||||
responses:
|
||||
'200':
|
||||
description: 'Values were sucessfully updated'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NotificationEmailSettings'
|
||||
/settings/notifications/discord:
|
||||
get:
|
||||
summary: Return current discord notification settings
|
||||
|
@@ -35,9 +35,10 @@ class EmailAgent implements NotificationAgent {
|
||||
}
|
||||
|
||||
private getNewEmail() {
|
||||
const settings = getSettings().notifications.agents.email;
|
||||
return new Email({
|
||||
message: {
|
||||
from: 'no-reply@os.sct.dev',
|
||||
from: settings.options.emailFrom,
|
||||
},
|
||||
send: true,
|
||||
transport: this.getSmtpTransport(),
|
||||
@@ -55,9 +56,6 @@ class EmailAgent implements NotificationAgent {
|
||||
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
|
||||
.forEach((user) => {
|
||||
const email = this.getNewEmail();
|
||||
logger.debug('Sending email notification', {
|
||||
label: 'Notifications',
|
||||
});
|
||||
|
||||
email.send({
|
||||
template: path.join(
|
||||
@@ -74,6 +72,7 @@ class EmailAgent implements NotificationAgent {
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.username,
|
||||
actionUrl: settings.applicationUrl,
|
||||
requestType: 'New Request',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -87,6 +86,72 @@ class EmailAgent implements NotificationAgent {
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMediaApprovedEmail(payload: NotificationPayload) {
|
||||
const settings = getSettings().main;
|
||||
try {
|
||||
const email = this.getNewEmail();
|
||||
|
||||
email.send({
|
||||
template: path.join(
|
||||
__dirname,
|
||||
'../../../templates/email/media-request'
|
||||
),
|
||||
message: {
|
||||
to: payload.notifyUser.email,
|
||||
},
|
||||
locals: {
|
||||
body: 'Your request for the following media has been approved:',
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.username,
|
||||
actionUrl: settings.applicationUrl,
|
||||
requestType: 'Request Approved',
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMediaAvailableEmail(payload: NotificationPayload) {
|
||||
const settings = getSettings().main;
|
||||
try {
|
||||
const email = this.getNewEmail();
|
||||
|
||||
email.send({
|
||||
template: path.join(
|
||||
__dirname,
|
||||
'../../../templates/email/media-request'
|
||||
),
|
||||
message: {
|
||||
to: payload.notifyUser.email,
|
||||
},
|
||||
locals: {
|
||||
body: 'Your requsested media is now available!',
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.username,
|
||||
actionUrl: settings.applicationUrl,
|
||||
requestType: 'Now Available',
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async send(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
@@ -97,6 +162,12 @@ class EmailAgent implements NotificationAgent {
|
||||
case Notification.MEDIA_PENDING:
|
||||
this.sendMediaRequestEmail(payload);
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
this.sendMediaApprovedEmail(payload);
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
this.sendMediaAvailableEmail(payload);
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
@@ -65,6 +65,7 @@ interface NotificationAgentDiscord extends NotificationAgent {
|
||||
|
||||
interface NotificationAgentEmail extends NotificationAgent {
|
||||
options: {
|
||||
emailFrom: string;
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
secure: boolean;
|
||||
@@ -120,6 +121,7 @@ class Settings {
|
||||
enabled: false,
|
||||
types: 0,
|
||||
options: {
|
||||
emailFrom: '',
|
||||
smtpHost: '127.0.0.1',
|
||||
smtpPort: 465,
|
||||
secure: false,
|
||||
|
@@ -364,4 +364,19 @@ settingsRoutes.post('/notifications/discord', (req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.discord);
|
||||
});
|
||||
|
||||
settingsRoutes.get('/notifications/email', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.email);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/email', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.email = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.email);
|
||||
});
|
||||
|
||||
export default settingsRoutes;
|
||||
|
@@ -1 +1 @@
|
||||
= `New Request: ${mediaName} - Overseerr`
|
||||
= `${requestType}: ${mediaName} - Overseerr`
|
||||
|
228
src/components/Settings/Notifications/NotificationsEmail.tsx
Normal file
228
src/components/Settings/Notifications/NotificationsEmail.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import Button from '../../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Axios from 'axios';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving...',
|
||||
});
|
||||
|
||||
const NotificationsEmail: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/email'
|
||||
);
|
||||
|
||||
const NotificationsDiscordSchema = Yup.object().shape({
|
||||
emailFrom: Yup.string().required(
|
||||
'You must provide an email sender address'
|
||||
),
|
||||
smtpHost: Yup.string().required('You must provide an SMTP host'),
|
||||
smtpPort: Yup.number().required('You must provide an SMTP port'),
|
||||
});
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
types: data.types,
|
||||
emailFrom: data.options.emailFrom,
|
||||
smtpHost: data.options.smtpHost,
|
||||
smtpPort: data.options.smtpPort,
|
||||
secure: data.options.secure,
|
||||
authUser: data.options.authUser,
|
||||
authPass: data.options.authPass,
|
||||
}}
|
||||
validationSchema={NotificationsDiscordSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await Axios.post('/api/v1/settings/notifications/email', {
|
||||
enabled: values.enabled,
|
||||
types: values.types,
|
||||
options: {
|
||||
emailFrom: values.emailFrom,
|
||||
smtpHost: values.smtpHost,
|
||||
smtpPort: values.smtpPort,
|
||||
secure: values.secure,
|
||||
authUser: values.authUser,
|
||||
authPass: values.authPass,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
// TODO show error
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting }) => {
|
||||
return (
|
||||
<Form>
|
||||
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
|
||||
<label
|
||||
htmlFor="isDefault"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
Agent Enabled
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
name="enabled"
|
||||
className="form-checkbox rounded-md h-6 w-6 text-indigo-600 transition duration-150 ease-in-out"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
Email Sender Address
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="emailFrom"
|
||||
name="emailFrom"
|
||||
type="text"
|
||||
placeholder="no-reply@example.com"
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
{errors.emailFrom && touched.emailFrom && (
|
||||
<div className="text-red-500 mt-2">{errors.emailFrom}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
SMTP Host
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="smtpHost"
|
||||
name="smtpHost"
|
||||
type="text"
|
||||
placeholder="localhost"
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
{errors.smtpHost && touched.smtpHost && (
|
||||
<div className="text-red-500 mt-2">{errors.smtpHost}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
SMTP Port
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="smtpPort"
|
||||
name="smtpPort"
|
||||
type="text"
|
||||
placeholder="465"
|
||||
className="form-input block w-24 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
{errors.smtpPort && touched.smtpPort && (
|
||||
<div className="text-red-500 mt-2">{errors.smtpPort}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
|
||||
<label
|
||||
htmlFor="isDefault"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
Enable SSL
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="secure"
|
||||
name="secure"
|
||||
className="form-checkbox rounded-md h-6 w-6 text-indigo-600 transition duration-150 ease-in-out"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
Auth User
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="authUser"
|
||||
name="authUser"
|
||||
type="text"
|
||||
placeholder="localhost"
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
Auth Pass
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="authPass"
|
||||
name="authPass"
|
||||
type="password"
|
||||
placeholder="localhost"
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsEmail;
|
@@ -26,7 +26,7 @@ const settingsRoutes: SettingsRoute[] = [
|
||||
},
|
||||
{
|
||||
text: 'Notifications',
|
||||
route: '/settings/notifications',
|
||||
route: '/settings/notifications/email',
|
||||
regex: /^\/settings\/notifications/,
|
||||
},
|
||||
{
|
||||
|
@@ -10,9 +10,9 @@ interface SettingsRoute {
|
||||
|
||||
const settingsRoutes: SettingsRoute[] = [
|
||||
{
|
||||
text: 'General',
|
||||
route: '/settings/notifications',
|
||||
regex: /^\/settings\/notifications$/,
|
||||
text: 'Email',
|
||||
route: '/settings/notifications/email',
|
||||
regex: /^\/settings\/notifications\/email/,
|
||||
},
|
||||
{
|
||||
text: 'Discord',
|
||||
|
@@ -2,11 +2,14 @@ import { NextPage } from 'next';
|
||||
import React from 'react';
|
||||
import SettingsLayout from '../../../components/Settings/SettingsLayout';
|
||||
import SettingsNotifications from '../../../components/Settings/SettingsNotifications';
|
||||
import NotificationsEmail from '../../../components/Settings/Notifications/NotificationsEmail';
|
||||
|
||||
const NotificationsPage: NextPage = () => {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<SettingsNotifications>N/A</SettingsNotifications>
|
||||
<SettingsNotifications>
|
||||
<NotificationsEmail />
|
||||
</SettingsNotifications>
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
Reference in New Issue
Block a user