feat(notifications): added ability to send test notifications

closes #309
This commit is contained in:
sct
2020-12-20 21:41:25 +09:00
parent 764db94f1b
commit 44a305426f
12 changed files with 378 additions and 55 deletions

View File

@@ -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<T extends NotificationAgentConfig> {
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<boolean>;

View File

@@ -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<NotificationAgentDiscord>
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<boolean> {
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;

View File

@@ -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<NotificationAgentEmail>
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;

View File

@@ -5,6 +5,7 @@ export enum Notification {
MEDIA_PENDING = 2,
MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8,
TEST_NOTIFICATION = 16,
}
class NotificationManager {

View File

@@ -53,18 +53,18 @@ interface PublicSettings {
initialized: boolean;
}
interface NotificationAgent {
export interface NotificationAgentConfig {
enabled: boolean;
types: number;
options: Record<string, unknown>;
}
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;

View File

@@ -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);

View File

@@ -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&amp;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.

View File

@@ -0,0 +1 @@
= `Test Notification - Overseerr`