feat(api): email notification agent

no ui yet built to configure it and currently only handles MEDIA_PENDING notification types
pull/183/head
sct 4 years ago
parent 0601b44687
commit 0962392e39

@ -21,6 +21,7 @@
"bowser": "^2.11.0",
"connect-typeorm": "^1.1.4",
"cookie-parser": "^1.4.5",
"email-templates": "^8.0.0",
"express": "^4.17.1",
"express-openapi-validator": "^3.16.15",
"express-session": "^1.17.1",
@ -29,8 +30,10 @@
"lodash": "^4.17.20",
"next": "9.5.4",
"node-schedule": "^1.3.2",
"nodemailer": "^6.4.16",
"nookies": "^2.4.0",
"plex-api": "^5.3.1",
"pug": "^3.0.0",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-intl": "^5.8.5",
@ -58,11 +61,13 @@
"@tailwindcss/typography": "^0.3.1",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/email-templates": "^7.1.0",
"@types/express": "^4.17.8",
"@types/express-session": "^1.17.0",
"@types/lodash": "^4.14.161",
"@types/node": "^14.10.0",
"@types/node-schedule": "^1.3.1",
"@types/nodemailer": "^6.4.0",
"@types/react": "^16.9.49",
"@types/react-dom": "^16.9.8",
"@types/react-toast-notifications": "^2.4.0",

@ -16,6 +16,7 @@ import logger from './logger';
import { startJobs } from './job/schedule';
import notificationManager from './lib/notifications';
import DiscordAgent from './lib/notifications/agents/discord';
import EmailAgent from './lib/notifications/agents/email';
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
@ -31,7 +32,7 @@ app
getSettings().load();
// Register Notification Agents
notificationManager.registerAgents([new DiscordAgent()]);
notificationManager.registerAgents([new DiscordAgent(), new EmailAgent()]);
// Start Jobs
startJobs();

@ -0,0 +1,106 @@
import type { NotificationAgent, NotificationPayload } from './agent';
import { Notification } from '..';
import path from 'path';
import { getSettings } from '../../settings';
import nodemailer from 'nodemailer';
import Email from 'email-templates';
import logger from '../../../logger';
import { getRepository } from 'typeorm';
import { User } from '../../../entity/User';
import { hasPermission, Permission } from '../../permissions';
class EmailAgent implements NotificationAgent {
public shouldSend(type: Notification): boolean {
const settings = getSettings();
if (settings.notifications.agents.email.enabled) {
return true;
}
return false;
}
private getSmtpTransport() {
const emailSettings = getSettings().notifications.agents.email.options;
return nodemailer.createTransport({
host: emailSettings.smtpHost,
port: emailSettings.smtpPort,
secure: emailSettings.secure,
auth: {
user: emailSettings.authUser,
pass: emailSettings.authPass,
},
});
}
private getNewEmail() {
return new Email({
message: {
from: 'no-reply@os.sct.dev',
},
send: true,
transport: this.getSmtpTransport(),
});
}
private async sendMediaRequestEmail(payload: NotificationPayload) {
const settings = getSettings().main;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
// Send to all users with the manage requests permission (or admins)
users
.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(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: user.email,
},
locals: {
body: 'A user has requested new media!',
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
actionUrl: settings.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
): Promise<boolean> {
logger.debug('Sending email notification', { label: 'Notifications' });
switch (type) {
case Notification.MEDIA_PENDING:
this.sendMediaRequestEmail(payload);
break;
}
return true;
}
}
export default EmailAgent;

@ -1,5 +1,6 @@
import fs from 'fs';
import path from 'path';
import { merge } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
export interface Library {
@ -44,6 +45,7 @@ export interface SonarrSettings extends DVRSettings {
export interface MainSettings {
apiKey: string;
applicationUrl: string;
}
interface PublicSettings {
@ -61,7 +63,18 @@ interface NotificationAgentDiscord extends NotificationAgent {
};
}
interface NotificationAgentEmail extends NotificationAgent {
options: {
smtpHost: string;
smtpPort: number;
secure: boolean;
authUser?: string;
authPass?: string;
};
}
interface NotificationAgents {
email: NotificationAgentEmail;
discord: NotificationAgentDiscord;
}
@ -88,6 +101,7 @@ class Settings {
this.data = {
main: {
apiKey: 'temp',
applicationUrl: '',
},
plex: {
name: '',
@ -102,6 +116,15 @@ class Settings {
},
notifications: {
agents: {
email: {
enabled: false,
types: 0,
options: {
smtpHost: '127.0.0.1',
smtpPort: 465,
secure: false,
},
},
discord: {
enabled: false,
types: 0,
@ -113,7 +136,7 @@ class Settings {
},
};
if (initialSettings) {
Object.assign<AllSettings, AllSettings>(this.data, initialSettings);
this.data = merge(this.data, initialSettings);
}
}
@ -194,7 +217,7 @@ class Settings {
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
if (data) {
this.data = Object.assign(this.data, JSON.parse(data));
this.data = merge(this.data, JSON.parse(data));
this.save();
}
return this.data;

@ -0,0 +1,114 @@
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='https://example.com' 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}
br
br
p(style='margin-top: 4px; text-align: center')
| #{mediaName}
table(cellpadding='0' cellspacing='0' role='presentation')
tr
td
table(cellpadding='0' cellspacing='0' role='presentation')
img(src=imageUrl alt='')
p
p(style='\
font-size: 16px;\
line-height: 24px;\
margin-top: 6px;\
margin-bottom: 20px;\
color: #51545e;\
')
| Requested by #{requestedBy} at #{timestamp}
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.

@ -0,0 +1,224 @@
<!DOCTYPE html>
<html
lang="en"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<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;amp;display=swap"
rel="stylesheet"
media="screen"
/>
<!--[if mso]>
<xml
><o:OfficeDocumentSettings
><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings
></xml
>
<style>
td,
th,
div,
p,
a,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Segoe UI', sans-serif;
mso-line-height-rule: exactly;
}
</style>
<![endif]-->
<style>
@media (max-width: 600px) {
.sm-w-full {
width: 100% !important;
}
}
</style>
</head>
<body
style="
margin: 0;
padding: 0;
width: 100%;
word-break: break-word;
-webkit-font-smoothing: antialiased;
background-color: #f2f4f6;
"
>
<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="https://example.com"
style="
text-shadow: 0 1px 0 #ffffff;
font-weight: 700;
font-size: 16px;
color: #a8aaaf;
text-decoration: none;
"
>
Overseerr
</a>
</td>
</tr>
<tr>
<td style="width: 100%" width="100%">
<table
align="center"
class="sm-w-full"
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}}
<br />
<br />
<p style="margin-top: 4px; text-align: center">
{{media_name}
</p>
<table
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tr>
<td>
<table
cellpadding="0"
cellspacing="0"
role="presentation"
>
<img src="{{image_url}}" alt="" />
<p></p>
</table>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p
style="
font-size: 16px;
line-height: 24px;
margin-top: 6px;
margin-bottom: 20px;
color: #51545e;
"
>
Requested by {{requester_name}} at {{timestamp}}
</p>
<p
style="
font-size: 13px;
line-height: 24px;
margin-top: 6px;
margin-bottom: 20px;
color: #51545e;
"
>
<a href="{{action_url}}" style="color: #3869d4"
>Open detail page</a
>
</p>
</td>
</tr>
</table>
</div>
<tr>
<td>
<table
align="center"
class="sm-w-full"
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.
</p>
</td>
</tr>
</table>
</td>
</tr>
</body>
</html>

@ -0,0 +1 @@
= `New Request: ${mediaName} - Overseerr`

@ -31,7 +31,7 @@ const ListView: React.FC<ListViewProps> = ({
No Results
</div>
)}
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7">
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{items?.map((title) => {
let titleCard: React.ReactNode;

@ -176,7 +176,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`}
alt=""
className="w-20 sm:w-28 rounded-md shadow-sm cursor-pointer"
className="w-20 sm:w-28 rounded-md shadow-sm cursor-pointer transition transform-gpu duration-300 scale-100 hover:scale-105 hover:shadow-md"
/>
</Link>
</div>

@ -207,7 +207,7 @@ const Slider: React.FC<SliderProps> = ({
</button>
</div>
<div
className="overflow-x-scroll whitespace-nowrap hide-scrollbar overscroll-x-contain -ml-4 -mr-4 px-2"
className="relative overflow-x-scroll whitespace-nowrap hide-scrollbar overscroll-x-contain -ml-4 -mr-4 px-2 overflow-y-auto"
ref={containerRef}
onScroll={onScroll}
>

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save