diff --git a/overseerr-api.yml b/overseerr-api.yml index 20901797..b52527ac 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1488,6 +1488,21 @@ paths: application/json: schema: $ref: '#/components/schemas/NotificationEmailSettings' + /settings/notifications/email/test: + post: + summary: Test the provided email settings + description: Sends a test notification to the email agent + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + responses: + '204': + description: Test notification attempted /settings/notifications/discord: get: summary: Return current discord notification settings @@ -1519,6 +1534,21 @@ paths: application/json: schema: $ref: '#/components/schemas/DiscordSettings' + /settings/notifications/discord/test: + post: + summary: Test the provided discord settings + description: Sends a test notification to the discord agent + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + responses: + '204': + description: Test notification attempted /settings/about: get: summary: Return current about stats diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index 15d57bca..d04cabf0 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -1,5 +1,6 @@ import { Notification } from '..'; import { User } from '../../../entity/User'; +import { NotificationAgentConfig } from '../../settings'; export interface NotificationPayload { subject: string; @@ -9,6 +10,15 @@ export interface NotificationPayload { extra?: { name: string; value: string }[]; } +export abstract class BaseAgent { + protected settings?: T; + public constructor(settings?: T) { + this.settings = settings; + } + + protected abstract getSettings(): T; +} + export interface NotificationAgent { shouldSend(type: Notification): boolean; send(type: Notification, payload: NotificationPayload): Promise; diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 008e9149..92348e43 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -1,8 +1,8 @@ import axios from 'axios'; import { Notification } from '..'; import logger from '../../../logger'; -import { getSettings } from '../../settings'; -import type { NotificationAgent, NotificationPayload } from './agent'; +import { getSettings, NotificationAgentDiscord } from '../../settings'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; enum EmbedColors { DEFAULT = 0, @@ -37,6 +37,11 @@ interface DiscordImageEmbed { width?: number; } +interface Field { + name: string; + value: string; + inline?: boolean; +} interface DiscordRichEmbed { title?: string; type?: 'rich'; // Always rich for webhooks @@ -61,11 +66,7 @@ interface DiscordRichEmbed { icon_url?: string; proxy_icon_url?: string; }; - fields?: { - name: string; - value: string; - inline?: boolean; - }[]; + fields?: Field[]; } interface DiscordWebhookPayload { @@ -75,27 +76,75 @@ interface DiscordWebhookPayload { tts: boolean; } -class DiscordAgent implements NotificationAgent { +class DiscordAgent + extends BaseAgent + implements NotificationAgent { + protected getSettings(): NotificationAgentDiscord { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.discord; + } + public buildEmbed( type: Notification, payload: NotificationPayload ): DiscordRichEmbed { let color = EmbedColors.DEFAULT; - let status = 'Unknown'; + + const fields: Field[] = []; switch (type) { case Notification.MEDIA_PENDING: color = EmbedColors.ORANGE; - status = 'Pending Approval'; + fields.push( + { + name: 'Requested By', + value: payload.notifyUser.username ?? '', + inline: true, + }, + { + name: 'Status', + value: 'Pending Approval', + inline: true, + } + ); break; case Notification.MEDIA_APPROVED: color = EmbedColors.PURPLE; - status = 'Processing Request'; + fields.push( + { + name: 'Requested By', + value: payload.notifyUser.username ?? '', + inline: true, + }, + { + name: 'Status', + value: 'Processing Request', + inline: true, + } + ); break; case Notification.MEDIA_AVAILABLE: color = EmbedColors.GREEN; - status = 'Available'; + fields.push( + { + name: 'Requested By', + value: payload.notifyUser.username ?? '', + inline: true, + }, + { + name: 'Status', + value: 'Available', + inline: true, + } + ); break; + default: + color = EmbedColors.DARK_PURPLE; } return { @@ -105,16 +154,7 @@ class DiscordAgent implements NotificationAgent { timestamp: new Date().toISOString(), author: { name: 'Overseerr' }, fields: [ - { - name: 'Requested By', - value: payload.notifyUser.username ?? '', - inline: true, - }, - { - name: 'Status', - value: status, - inline: true, - }, + ...fields, // If we have extra data, map it to fields for discord notifications ...(payload.extra ?? []).map((extra) => ({ name: extra.name, @@ -130,12 +170,7 @@ class DiscordAgent implements NotificationAgent { // TODO: Add checking for type here once we add notification type filters for agents // eslint-disable-next-line @typescript-eslint/no-unused-vars public shouldSend(_type: Notification): boolean { - const settings = getSettings(); - - if ( - settings.notifications.agents.discord?.enabled && - settings.notifications.agents.discord?.options?.webhookUrl - ) { + if (this.getSettings().enabled && this.getSettings().options.webhookUrl) { return true; } @@ -146,11 +181,9 @@ class DiscordAgent implements NotificationAgent { type: Notification, payload: NotificationPayload ): Promise { - const settings = getSettings(); logger.debug('Sending discord notification', { label: 'Notifications' }); try { - const webhookUrl = - settings.notifications.agents.discord?.options?.webhookUrl; + const webhookUrl = this.getSettings().options.webhookUrl; if (!webhookUrl) { return false; diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 18552525..354a5150 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -1,7 +1,7 @@ -import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; import { Notification } from '..'; import path from 'path'; -import { getSettings } from '../../settings'; +import { getSettings, NotificationAgentEmail } from '../../settings'; import nodemailer from 'nodemailer'; import Email from 'email-templates'; import logger from '../../../logger'; @@ -9,13 +9,25 @@ import { getRepository } from 'typeorm'; import { User } from '../../../entity/User'; import { Permission } from '../../permissions'; -class EmailAgent implements NotificationAgent { +class EmailAgent + extends BaseAgent + implements NotificationAgent { + protected getSettings(): NotificationAgentEmail { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.email; + } + // TODO: Add checking for type here once we add notification type filters for agents // eslint-disable-next-line @typescript-eslint/no-unused-vars public shouldSend(_type: Notification): boolean { - const settings = getSettings(); + const settings = this.getSettings(); - if (settings.notifications.agents.email.enabled) { + if (settings.enabled) { return true; } @@ -23,7 +35,7 @@ class EmailAgent implements NotificationAgent { } private getSmtpTransport() { - const emailSettings = getSettings().notifications.agents.email.options; + const emailSettings = this.getSettings().options; return nodemailer.createTransport({ host: emailSettings.smtpHost, @@ -40,7 +52,7 @@ class EmailAgent implements NotificationAgent { } private getNewEmail() { - const settings = getSettings().notifications.agents.email; + const settings = this.getSettings(); return new Email({ message: { from: settings.options.emailFrom, @@ -51,7 +63,8 @@ class EmailAgent implements NotificationAgent { } private async sendMediaRequestEmail(payload: NotificationPayload) { - const settings = getSettings().main; + // This is getting main settings for the whole app + const applicationUrl = getSettings().main.applicationUrl; try { const userRepository = getRepository(User); const users = await userRepository.find(); @@ -76,7 +89,7 @@ class EmailAgent implements NotificationAgent { imageUrl: payload.image, timestamp: new Date().toTimeString(), requestedBy: payload.notifyUser.username, - actionUrl: settings.applicationUrl, + actionUrl: applicationUrl, requestType: 'New Request', }, }); @@ -92,7 +105,8 @@ class EmailAgent implements NotificationAgent { } private async sendMediaApprovedEmail(payload: NotificationPayload) { - const settings = getSettings().main; + // This is getting main settings for the whole app + const applicationUrl = getSettings().main.applicationUrl; try { const email = this.getNewEmail(); @@ -110,7 +124,7 @@ class EmailAgent implements NotificationAgent { imageUrl: payload.image, timestamp: new Date().toTimeString(), requestedBy: payload.notifyUser.username, - actionUrl: settings.applicationUrl, + actionUrl: applicationUrl, requestType: 'Request Approved', }, }); @@ -125,7 +139,8 @@ class EmailAgent implements NotificationAgent { } private async sendMediaAvailableEmail(payload: NotificationPayload) { - const settings = getSettings().main; + // This is getting main settings for the whole app + const applicationUrl = getSettings().main.applicationUrl; try { const email = this.getNewEmail(); @@ -143,7 +158,7 @@ class EmailAgent implements NotificationAgent { imageUrl: payload.image, timestamp: new Date().toTimeString(), requestedBy: payload.notifyUser.username, - actionUrl: settings.applicationUrl, + actionUrl: applicationUrl, requestType: 'Now Available', }, }); @@ -157,6 +172,32 @@ class EmailAgent implements NotificationAgent { } } + private async sendTestEmail(payload: NotificationPayload) { + // This is getting main settings for the whole app + const applicationUrl = getSettings().main.applicationUrl; + try { + const email = this.getNewEmail(); + + email.send({ + template: path.join(__dirname, '../../../templates/email/test-email'), + message: { + to: payload.notifyUser.email, + }, + locals: { + body: payload.message, + actionUrl: applicationUrl, + }, + }); + 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 @@ -173,6 +214,9 @@ class EmailAgent implements NotificationAgent { case Notification.MEDIA_AVAILABLE: this.sendMediaAvailableEmail(payload); break; + case Notification.TEST_NOTIFICATION: + this.sendTestEmail(payload); + break; } return true; diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index 91be4c5d..c826bfeb 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -5,6 +5,7 @@ export enum Notification { MEDIA_PENDING = 2, MEDIA_APPROVED = 4, MEDIA_AVAILABLE = 8, + TEST_NOTIFICATION = 16, } class NotificationManager { diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 32756363..02e6b46b 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -53,18 +53,18 @@ interface PublicSettings { initialized: boolean; } -interface NotificationAgent { +export interface NotificationAgentConfig { enabled: boolean; types: number; options: Record; } -interface NotificationAgentDiscord extends NotificationAgent { +export interface NotificationAgentDiscord extends NotificationAgentConfig { options: { webhookUrl: string; }; } -interface NotificationAgentEmail extends NotificationAgent { +export interface NotificationAgentEmail extends NotificationAgentConfig { options: { emailFrom: string; smtpHost: string; diff --git a/server/routes/settings.ts b/server/routes/settings.ts index 8f72e588..a55b861f 100644 --- a/server/routes/settings.ts +++ b/server/routes/settings.ts @@ -21,6 +21,9 @@ import Media from '../entity/Media'; import { MediaRequest } from '../entity/MediaRequest'; import { getAppVersion } from '../utils/appVersion'; import { SettingsAboutResponse } from '../interfaces/api/settingsInterfaces'; +import { Notification } from '../lib/notifications'; +import DiscordAgent from '../lib/notifications/agents/discord'; +import EmailAgent from '../lib/notifications/agents/email'; const settingsRoutes = Router(); @@ -448,6 +451,25 @@ settingsRoutes.post('/notifications/discord', (req, res) => { res.status(200).json(settings.notifications.agents.discord); }); +settingsRoutes.post('/notifications/discord/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const discordAgent = new DiscordAgent(req.body); + discordAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + settingsRoutes.get('/notifications/email', (_req, res) => { const settings = getSettings(); @@ -463,6 +485,25 @@ settingsRoutes.post('/notifications/email', (req, res) => { res.status(200).json(settings.notifications.agents.email); }); +settingsRoutes.post('/notifications/email/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const emailAgent = new EmailAgent(req.body); + emailAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + settingsRoutes.get('/about', async (req, res) => { const mediaRepository = getRepository(Media); const mediaRequestRepository = getRepository(MediaRequest); diff --git a/server/templates/email/test-email/html.pug b/server/templates/email/test-email/html.pug new file mode 100644 index 00000000..46f4ca2c --- /dev/null +++ b/server/templates/email/test-email/html.pug @@ -0,0 +1,96 @@ +doctype html +head + meta(charset='utf-8') + meta(name='x-apple-disable-message-reformatting') + meta(http-equiv='x-ua-compatible' content='ie=edge') + meta(name='viewport' content='width=device-width, initial-scale=1') + meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') + link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap' rel='stylesheet' media='screen') + //if mso + xml + o:officedocumentsettings + o:pixelsperinch 96 + style. + td, + th, + div, + p, + a, + h1, + h2, + h3, + h4, + h5, + h6 { + font-family: 'Segoe UI', sans-serif; + mso-line-height-rule: exactly; + } + style. + @media (max-width: 600px) { + .sm-w-full { + width: 100% !important; + } + } +div(role='article' aria-roledescription='email' aria-label='' lang='en') + table(style="\ + background-color: #f2f4f6;\ + font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\ + width: 100%;\ + " width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center') + table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center' style='\ + font-size: 16px;\ + padding-top: 25px;\ + padding-bottom: 25px;\ + text-align: center;\ + ') + a(href=actionUrl style='\ + text-shadow: 0 1px 0 #ffffff;\ + font-weight: 700;\ + font-size: 16px;\ + color: #a8aaaf;\ + text-decoration: none;\ + ') + | Overseerr + tr + td(style='width: 100%' width='100%') + table.sm-w-full(align='center' style='\ + background-color: #ffffff;\ + margin-left: auto;\ + margin-right: auto;\ + width: 570px;\ + ' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation') + tr + td(style='padding: 45px') + div(style='font-size: 16px') + | #{body} + p(style='\ + font-size: 13px;\ + line-height: 24px;\ + margin-top: 6px;\ + margin-bottom: 20px;\ + color: #51545e;\ + ') + a(href=actionUrl style='color: #3869d4') Open Overseerr +tr + td + table.sm-w-full(align='center' style='\ + margin-left: auto;\ + margin-right: auto;\ + text-align: center;\ + width: 570px;\ + ' width='570' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center' style='font-size: 16px; padding: 45px') + p(style='\ + font-size: 13px;\ + line-height: 24px;\ + margin-top: 6px;\ + margin-bottom: 20px;\ + text-align: center;\ + color: #a8aaaf;\ + ') + | Overseerr. diff --git a/server/templates/email/test-email/subject.pug b/server/templates/email/test-email/subject.pug new file mode 100644 index 00000000..6e50c1b5 --- /dev/null +++ b/server/templates/email/test-email/subject.pug @@ -0,0 +1 @@ += `Test Notification - Overseerr` diff --git a/src/components/Settings/Notifications/NotificationsDiscord.tsx b/src/components/Settings/Notifications/NotificationsDiscord.tsx index 2f70269c..6bf001e7 100644 --- a/src/components/Settings/Notifications/NotificationsDiscord.tsx +++ b/src/components/Settings/Notifications/NotificationsDiscord.tsx @@ -4,7 +4,7 @@ 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 axios from 'axios'; import * as Yup from 'yup'; import { useToasts } from 'react-toast-notifications'; @@ -17,6 +17,8 @@ const messages = defineMessages({ webhookUrlPlaceholder: 'Server Settings -> Integrations -> Webhooks', discordsettingssaved: 'Discord notification settings saved!', discordsettingsfailed: 'Discord notification settings failed to save.', + testsent: 'Test notification sent!', + test: 'Test', }); const NotificationsDiscord: React.FC = () => { @@ -46,7 +48,7 @@ const NotificationsDiscord: React.FC = () => { validationSchema={NotificationsDiscordSchema} onSubmit={async (values) => { try { - await Axios.post('/api/v1/settings/notifications/discord', { + await axios.post('/api/v1/settings/notifications/discord', { enabled: values.enabled, types: values.types, options: { @@ -67,7 +69,22 @@ const NotificationsDiscord: React.FC = () => { } }} > - {({ errors, touched, isSubmitting }) => { + {({ errors, touched, isSubmitting, values, isValid }) => { + const testSettings = async () => { + await axios.post('/api/v1/settings/notifications/discord/test', { + enabled: true, + types: values.types, + options: { + webhookUrl: values.webhookUrl, + }, + }); + + addToast(intl.formatMessage(messages.testsent), { + appearance: 'info', + autoDismiss: true, + }); + }; + return (
@@ -112,11 +129,24 @@ const NotificationsDiscord: React.FC = () => {
+ + + +