Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions server/lib/notifications/agents/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import PreparedEmail from '@server/lib/email';
import type { NotificationAgentEmail } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import {
getAvailableMediaServerName,
getAvailableMediaServerUrl,
} from '@server/utils/mediaServerHelper';
import type { EmailOptions } from 'email-templates';
import path from 'path';
import validator from 'validator';
Expand All @@ -17,6 +21,11 @@ class EmailAgent
extends BaseAgent<NotificationAgentEmail>
implements NotificationAgent
{
/**
* Returns this agents notification settings
* Uses a cached copy when available
* @protected
*/
protected getSettings(): NotificationAgentEmail {
if (this.settings) {
return this.settings;
Expand All @@ -27,6 +36,10 @@ class EmailAgent
return settings.notifications.agents.email;
}

/**
* Determines whether this agent is able to send email notifications
* Returns true only if the agent is enabled and the required SMTP settings are configured
*/
public shouldSend(): boolean {
const settings = this.getSettings();

Expand All @@ -42,6 +55,18 @@ class EmailAgent
return false;
}

/**
* Builds the email payload for an email notification
*
* For all notifications it has a button to take you to the media in Seerr
* For request notifications it includes a button that links to the media in the chosen media server
* @param type Type of notification being sent
* @param payload Notification context
* @param recipientEmail The recipient's email address
* @param recipientName The recipient's name
* @returns Email notification payload
* @private
*/
private buildMessage(
type: Notification,
payload: NotificationPayload,
Expand Down Expand Up @@ -118,6 +143,10 @@ class EmailAgent
break;
}

const { mediaServerType } = settings.main;
const mediaServerName = getAvailableMediaServerName(mediaServerType);
const mediaServerUrl = getAvailableMediaServerUrl(payload);

return {
template: path.join(
__dirname,
Expand All @@ -141,6 +170,8 @@ class EmailAgent
applicationTitle,
recipientName,
recipientEmail,
mediaServerActionUrl: mediaServerUrl,
mediaServerName,
},
};
} else if (payload.issue) {
Expand Down Expand Up @@ -194,6 +225,14 @@ class EmailAgent
return undefined;
}

/**
* Sends an email notification to the recipients in the payload.
*
* Respects per-user notification settings, and it validates the email address before sending the notification
* @param type The type of notification being sent
* @param payload Notification context
* @returns True if the notification has successfully sent, else returns False
*/
public async send(
type: Notification,
payload: NotificationPayload
Expand Down
81 changes: 67 additions & 14 deletions server/lib/notifications/agents/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import type { NotificationAgentSlack } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import {
getAvailableMediaServerName,
getAvailableMediaServerUrl,
} from '@server/utils/mediaServerHelper';
import axios from 'axios';
import { Notification, hasNotificationType } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
Expand Down Expand Up @@ -49,6 +53,11 @@ class SlackAgent
extends BaseAgent<NotificationAgentSlack>
implements NotificationAgent
{
/**
* Returns this agent's notification settings
* Uses a cached copy when available
* @protected
*/
protected getSettings(): NotificationAgentSlack {
if (this.settings) {
return this.settings;
Expand All @@ -59,12 +68,21 @@ class SlackAgent
return settings.notifications.agents.slack;
}

/**
* Builds the Slack Embed for the Slack notification that will be sent
*
* For all notifications it has a button to take you to the media in Seerr
* For request notifications it includes a button that links to the media in the chosen media server
* @param type The type of notification being sent
* @param payload Notification context
* @returns The Slack embed payload
*/
public buildEmbed(
type: Notification,
payload: NotificationPayload
): SlackBlockEmbed {
const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { applicationUrl, applicationTitle, mediaServerType } = settings.main;
const { embedPoster } = settings.notifications.agents.slack;

const fields: EmbedField[] = [];
Expand Down Expand Up @@ -187,22 +205,43 @@ class SlackAgent
: undefined
: undefined;

const actionElements: Element[] = [];

if (url) {
actionElements.push({
action_id: 'open-in-seerr',
type: 'button',
url,
text: {
type: 'plain_text',
text: `View ${
payload.issue ? 'Issue' : 'Media'
} in ${applicationTitle}`,
},
});
}

if (!payload.issue) {
const mediaServerName = getAvailableMediaServerName(mediaServerType);
const mediaServerUrl = getAvailableMediaServerUrl(payload);

if (mediaServerUrl) {
actionElements.push({
action_id: 'open-in-mediaServer',
type: 'button',
url: mediaServerUrl,
text: {
type: 'plain_text',
text: `Play on ${mediaServerName}`,
},
});
}
}

if (actionElements.length > 0) {
blocks.push({
type: 'actions',
elements: [
{
action_id: 'open-in-seerr',
type: 'button',
url,
text: {
type: 'plain_text',
text: `View ${
payload.issue ? 'Issue' : 'Media'
} in ${applicationTitle}`,
},
},
],
elements: actionElements,
});
}

Expand All @@ -212,6 +251,10 @@ class SlackAgent
};
}

/**
* Determines whether this agent is able to send Slack notifications
* Returns true only if the agent is enabled and a webhook URL is configured
*/
public shouldSend(): boolean {
const settings = this.getSettings();

Expand All @@ -222,6 +265,16 @@ class SlackAgent
return false;
}

/**
* Sends a Slack notification to the configured Slack webhook
*
* Returns true when notifications are skipped, disabled/type not enabled, or when the webhook call succeeds
* Returns false only when the webhook call fails
*
* @param type The type of notification being sent
* @param payload Notification context
* @returns True if the notification was sent successfully, otherwise False
*/
public async send(
type: Notification,
payload: NotificationPayload
Expand Down
52 changes: 50 additions & 2 deletions server/lib/notifications/agents/telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { User } from '@server/entity/User';
import type { NotificationAgentTelegram } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import {
getAvailableMediaServerName,
getAvailableMediaServerUrl,
} from '@server/utils/mediaServerHelper';
import axios from 'axios';
import {
Notification,
Expand Down Expand Up @@ -37,6 +41,11 @@ class TelegramAgent
{
private baseUrl = 'https://api.telegram.org/';

/**
* Returns this agent's notification settings.
* Uses a cached copy when available.
* @protected
*/
protected getSettings(): NotificationAgentTelegram {
if (this.settings) {
return this.settings;
Expand All @@ -47,6 +56,10 @@ class TelegramAgent
return settings.notifications.agents.telegram;
}

/**
* Determines whether this agent is able to send Telegram notifications
* Returns True only if Telegram notifications are enabled and the bot API token is filled in, else returns False
*/
public shouldSend(): boolean {
const settings = this.getSettings();

Expand All @@ -57,16 +70,35 @@ class TelegramAgent
return false;
}

/**
* Escapes text for Telegram MarkdownV2
* Telegram requires some characters to be escaped to prevent any formatting issues
* @param text The unescaped input
* @returns Escaped text that is safe for MarkdownV2
* @private
*/
private escapeText(text: string | undefined): string {
return text ? text.replace(/[_*[\]()~>#+=|{}.!-]/gi, (x) => '\\' + x) : '';
return text
? text.replace(/[_*[\]()~`\\>#+=|{}.!-]/gi, (x) => '\\' + x)
: '';
}

/**
* Builds the Telegram notification payload that will be sent
*
* For all notifications it has a button to take you to the media in Seerr
* For request notifications it includes a button that links to the media in the chosen media server
* @param type The type of notification being sent
* @param payload Notification context
* @returns Telegram notification payload
* @private
*/
private getNotificationPayload(
type: Notification,
payload: NotificationPayload
): Partial<TelegramMessagePayload | TelegramPhotoPayload> {
const settings = getSettings();
const { applicationUrl, applicationTitle } = settings.main;
const { applicationUrl, applicationTitle, mediaServerType } = settings.main;
const { embedPoster } = settings.notifications.agents.telegram;

/* eslint-disable no-useless-escape */
Expand Down Expand Up @@ -142,6 +174,15 @@ class TelegramAgent
payload.issue ? 'Issue' : 'Media'
} in ${this.escapeText(applicationTitle)}\]\(${url}\)`;
}

if (!payload.issue) {
const mediaServerName = getAvailableMediaServerName(mediaServerType);
const mediaServerUrl = getAvailableMediaServerUrl(payload);

if (mediaServerUrl) {
message += `\n\[Play on ${this.escapeText(mediaServerName)}\]\(${mediaServerUrl.replace(/\)/g, '\\)')}\)`;
}
}
/* eslint-enable */

return embedPoster && payload.image
Expand All @@ -156,6 +197,13 @@ class TelegramAgent
};
}

/**
* Sends the Telegram notification for the provided type using the payload
*
* @param type The type of notification being sent
* @param payload Notification payload
* @returns True if the notification was sent successfully, otherwise False
*/
public async send(
type: Notification,
payload: NotificationPayload
Expand Down
6 changes: 6 additions & 0 deletions server/templates/email/media-request/html.pug
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,9 @@ div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);')
| View Media in #{applicationTitle}
if mediaServerActionUrl
tr
td
a(href=mediaServerActionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);')
| Play on #{mediaServerName}
47 changes: 47 additions & 0 deletions server/utils/mediaServerHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { MediaServerType } from '@server/constants/server';

/**
* Returns the display name of the media server being used in the seerr installation
* If neither Emby nor Plex is detected it falls back to Jellyfin
* @param currentMediaServer The currently configured media server type from settings
* @returns Human readable media server name
*/
export function getAvailableMediaServerName(
currentMediaServer: MediaServerType
): string {
if (currentMediaServer === MediaServerType.EMBY) {
return 'Emby';
}

if (currentMediaServer === MediaServerType.PLEX) {
return 'Plex';
}

return 'Jellyfin';
}

/**
* This function returns the URL of the media directly in the media server
* Used later on in the email as a button to send directly to
*
* Prefers the 4K URL when the request is marked as 4K and prefers the
* standard URL otherwise. If the preferred URL is unavailable, it falls
* back to the alternative resolution URL to ensure a playable link is
* returned wherever possible.
*
* @param payload Notification payload containing media and request information
* @returns Media server URL or undefined if it's unavailable
*/
export function getAvailableMediaServerUrl(payload: {
request?: { is4k?: boolean };
media?: {
mediaUrl?: string;
mediaUrl4k?: string;
};
}): string | undefined {
const wants4k = payload.request?.is4k;
const url4k = payload.media?.mediaUrl4k;
const url = payload.media?.mediaUrl;

return (wants4k ? (url4k ?? url) : (url ?? url4k)) || undefined;
}