mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat(api): email notification agent
no ui yet built to configure it and currently only handles MEDIA_PENDING notification types
This commit is contained in:
@@ -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();
|
||||
|
106
server/lib/notifications/agents/email.ts
Normal file
106
server/lib/notifications/agents/email.ts
Normal file
@@ -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;
|
||||
|
114
server/templates/email/media-request/html.pug
Normal file
114
server/templates/email/media-request/html.pug
Normal file
@@ -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&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.
|
224
server/templates/email/media-request/media-request.html
Normal file
224
server/templates/email/media-request/media-request.html
Normal file
@@ -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;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>
|
1
server/templates/email/media-request/subject.pug
Normal file
1
server/templates/email/media-request/subject.pug
Normal file
@@ -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}
|
||||
>
|
||||
|
Reference in New Issue
Block a user