Skip to content

feat(notifications): enable multiple notification agent instances#1608

Open
Schrottfresser wants to merge 70 commits intoseerr-team:developfrom
Schrottfresser:notifications-rework
Open

feat(notifications): enable multiple notification agent instances#1608
Schrottfresser wants to merge 70 commits intoseerr-team:developfrom
Schrottfresser:notifications-rework

Conversation

@Schrottfresser
Copy link
Copy Markdown
Contributor

@Schrottfresser Schrottfresser commented Apr 24, 2025

Description

This PR will enable Jellyseerr to have multiple instances of notification agents. Therefore the entire notifications frontend was reworked and all notifications endpoints and the data structure was reworked.
For email password resetting and such features a default agent can be selected for each agent type.

Screenshots / Logs (if applicable)

2025-05-22_00-09
2025-05-22_00-10

How Has This Been Tested?

This new functionality has been successfully tested by me with my home server and Jellyfin instance. There was no testing done with Plex although this PR is not supposed to change anything related to it.
Not every notification type has been tested because the logic wasn't changed for any. Registration and de registration of instances was tested as well as UI behavior (for every type) and the new default instance logic.

Anyone interested in this feature (especially with a Plex instance) is very welcome to checkout this branch and test it out!

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy)
  • I have updated the documentation accordingly.
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract
  • Database migration (if required)

Do I need to do a migration for this and move the old notification instances in the new logic and setting them as default respectively?

Summary by CodeRabbit

  • New Features

    • Unified, instance-based notification settings allowing multiple instances per provider
    • Paginated list with create/edit modals, per-instance test, enable/disable and default toggles
  • UI Improvements

    • New accessible ToggleSwitch component
    • Dropdown for quick creation of notification instances; improved list, pagination and modal UX
  • Bug Fixes

    • Default webhook payload cleared to an empty value for safer testing and storage

@Schrottfresser Schrottfresser force-pushed the notifications-rework branch 2 times, most recently from 9a25506 to c2fd0cc Compare May 5, 2025 09:44
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 6, 2025

This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.

@github-actions github-actions Bot added the merge conflict Cannot merge due to merge conflicts label May 6, 2025
@Schrottfresser Schrottfresser force-pushed the notifications-rework branch from 7649231 to ba68fc0 Compare May 7, 2025 14:14
@github-actions github-actions Bot added merge conflict Cannot merge due to merge conflicts and removed merge conflict Cannot merge due to merge conflicts labels May 7, 2025
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 9, 2025

This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.

@Schrottfresser Schrottfresser force-pushed the notifications-rework branch from ba68fc0 to 04210c6 Compare May 9, 2025 15:47
@github-actions github-actions Bot removed the merge conflict Cannot merge due to merge conflicts label May 9, 2025
@Schrottfresser
Copy link
Copy Markdown
Contributor Author

I extracted the settings interfaces from server/lib/settings/index.ts to server/interfaces/settings.ts to be able to include these interfaces in the client. I also find it more clean to have these interfaces in a separate file.
Let me know if there is another path where this file should be located, or if the location is fine pls.

@gauthier-th
Copy link
Copy Markdown
Member

to be able to include these interfaces in the client

Why? You don't need to move these to use them in the client?
But yes, given the amount of types for settings a dedicated file like you did could be more clean.

@Schrottfresser
Copy link
Copy Markdown
Contributor Author

to be able to include these interfaces in the client

Why? You don't need to move these to use them in the client?

When trying to import interfaces or types from the server/lib/settings/index.ts i got an error because of the import fs from 'fs/promises' which is node exclusive and thus not available in the client. There are other imports like path which would throw an error too i think.

@Schrottfresser Schrottfresser force-pushed the notifications-rework branch from b4aaaf3 to 73aba35 Compare May 18, 2025 11:21
@Schrottfresser
Copy link
Copy Markdown
Contributor Author

This PR is feature complete now. I want to extract the table component in future which will not influence any features.
Extensive testing is welcome! I did not test this changes with Plex and the entire default notification agent logic.
I am open for reviews and critical thoughts!

@Schrottfresser Schrottfresser marked this pull request as ready for review May 21, 2025 22:30
@github-actions
Copy link
Copy Markdown

This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.

@github-actions github-actions Bot added the merge conflict Cannot merge due to merge conflicts label Jun 13, 2025
@github-actions github-actions Bot added merge conflict Cannot merge due to merge conflicts and removed merge conflict Cannot merge due to merge conflicts labels Jun 14, 2025
@github-actions
Copy link
Copy Markdown

This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.

@github-actions github-actions Bot removed the merge conflict Cannot merge due to merge conflicts label Jul 20, 2025
@github-actions
Copy link
Copy Markdown

This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.

@github-actions github-actions Bot added the merge conflict Cannot merge due to merge conflicts label Aug 19, 2025
@github-actions github-actions Bot removed the merge conflict Cannot merge due to merge conflicts label Aug 29, 2025
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/components/Common/ToggleSwitch/index.tsx (1)

17-29: ⚠️ Potential issue | 🟠 Major

Disabled mode is still interactive (previously reported).

disabled only changes opacity. Line 20 and Line 24 still trigger onToggle, and the control remains focusable at Line 17.

Suggested fix
     <span
       role="checkbox"
-      tabIndex={0}
+      tabIndex={disabled ? -1 : 0}
+      aria-disabled={disabled || undefined}
       aria-checked={isToggled}
       onClick={() => {
+        if (disabled) return;
         onToggle();
       }}
       onKeyDown={(e) => {
+        if (disabled) return;
         if (e.key === 'Enter' || e.key === 'Space') {
           onToggle();
         }
       }}
       className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${
-        disabled ? 'opacity-50' : ''
+        disabled ? 'cursor-not-allowed opacity-50' : ''
       }`}
     >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Common/ToggleSwitch/index.tsx` around lines 17 - 29, The
ToggleSwitch remains interactive when disabled because tabIndex, onClick and
onKeyDown always run; update the component so that when the disabled prop is
true you (1) set tabIndex to -1 instead of 0, (2) add aria-disabled="true", (3)
guard onClick and onKeyDown (or remove their handlers) so they do not call
onToggle when disabled, and (4) optionally add a CSS class like
pointer-events-none in the className to prevent pointer interaction—apply these
changes around the existing attributes (tabIndex, aria-checked, onClick,
onKeyDown, className) and use isToggled and onToggle as the referenced symbols
to implement the guards.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/Common/ToggleSwitch/index.tsx`:
- Around line 22-25: The onKeyDown handler in the ToggleSwitch component uses
the outdated check e.key === 'Space' and doesn't prevent default browser
behavior; update the handler where onKeyDown is defined to check for Enter or
e.key === ' ' (single space character) and call e.preventDefault() before
invoking onToggle(), ensuring Space activates the toggle in modern browsers and
suppresses the browser's default action.

---

Duplicate comments:
In `@src/components/Common/ToggleSwitch/index.tsx`:
- Around line 17-29: The ToggleSwitch remains interactive when disabled because
tabIndex, onClick and onKeyDown always run; update the component so that when
the disabled prop is true you (1) set tabIndex to -1 instead of 0, (2) add
aria-disabled="true", (3) guard onClick and onKeyDown (or remove their handlers)
so they do not call onToggle when disabled, and (4) optionally add a CSS class
like pointer-events-none in the className to prevent pointer interaction—apply
these changes around the existing attributes (tabIndex, aria-checked, onClick,
onKeyDown, className) and use isToggled and onToggle as the referenced symbols
to implement the guards.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 16ee83e and 5a48498.

📒 Files selected for processing (3)
  • src/components/Common/ToggleSwitch/index.tsx
  • src/components/RequestModal/CollectionRequestModal.tsx
  • src/components/RequestModal/TvRequestModal.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/RequestModal/TvRequestModal.tsx

Comment thread src/components/Common/ToggleSwitch/index.tsx
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (2)
src/components/Settings/SettingsNotifications/NotificationModal/TelegramModal.tsx (1)

83-96: ⚠️ Potential issue | 🟠 Major

messageThreadId should be optional, and .matches() needs excludeEmptyString.

Two issues with this validation:

  1. The field is marked .required() when enabled && types, but the UI shows no required indicator (no asterisk at line 322). Thread/Topic ID is optional—only used for group chats with topics enabled.

  2. The .matches() is chained unconditionally after .when(), so empty strings fail the regex even when the field is optional (the otherwise case).

🐛 Proposed fix
     messageThreadId: Yup.string()
       .when(['enabled', 'types'], {
         is: (enabled: boolean, types: number) => enabled && !!types,
-        then: Yup.string()
-          .nullable()
-          .required(
-            intl.formatMessage(messages.telegramValidationMessageThreadId)
-          ),
+        then: Yup.string().nullable(),
         otherwise: Yup.string().nullable(),
       })
       .matches(
         /^\d+$/,
-        intl.formatMessage(messages.telegramValidationMessageThreadId)
+        {
+          message: intl.formatMessage(messages.telegramValidationMessageThreadId),
+          excludeEmptyString: true,
+        }
       ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/TelegramModal.tsx`
around lines 83 - 96, The messageThreadId validation incorrectly forces
requiredness and always applies the regex: update the Yup schema for
messageThreadId so it is not .required() in the then branch (make it .nullable()
and optional) and move the .matches() into the conditional so it only applies
when a non-empty value is present (use .matches(regex, msg, {
excludeEmptyString: true }) or call .matches(...).nullable() inside the when so
empty strings are allowed); reference messageThreadId, the Yup .when(...) block,
the .matches(...) call and
intl.formatMessage(messages.telegramValidationMessageThreadId) when making the
change.
src/components/Settings/SettingsNotifications/NotificationModal/EmailModal.tsx (1)

79-85: ⚠️ Potential issue | 🟠 Major

Fix remaining Biome lint blocker in Yup conditional schema.

Lines 79-85, 91-99, 100-108, 110-118, and 123-129 still use object-form .when(..., { then, otherwise }), which triggers lint/suspicious/noThenProperty. Please refactor these to callback-form .when(...) conditionals to preserve behavior without then/otherwise keys.

Suggested pattern (apply to all five blocks)
- emailFrom: Yup.string().when('enabled', {
-   is: true,
-   then: Yup.string().nullable().required(intl.formatMessage(messages.emailValidation)),
-   otherwise: Yup.string().nullable(),
- })
+ emailFrom: Yup.string().when('enabled', (enabled, schema) =>
+   enabled
+     ? schema.nullable().required(intl.formatMessage(messages.emailValidation))
+     : schema.nullable()
+ )
#!/bin/bash
set -euo pipefail

FILE="src/components/Settings/SettingsNotifications/NotificationModal/EmailModal.tsx"

echo "Current Yup.when usage with potential then/otherwise keys:"
rg -n '\.when\(' "$FILE" -A10 -B1 | rg -n 'when\(|then:|otherwise:'

if command -v biome >/dev/null 2>&1; then
  echo
  echo "Biome check output for this file:"
  biome check "$FILE" || true
else
  echo
  echo "Biome CLI not available locally; verify via CI lint output."
fi

Also applies to: 91-99, 100-108, 110-118, 123-129

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/EmailModal.tsx`
around lines 79 - 85, Replace the object-form Yup.when(...) with the
callback-form in EmailModal.tsx for all conditional schema blocks (the five
occurrences that currently use .when('enabled', { is: true, then: ...,
otherwise: ... })); specifically, convert each to the function form
.when('enabled', (enabled, schema) => enabled ?
schema.concat(Yup.string().nullable().required(intl.formatMessage(messages.emailValidation)))
: schema.concat(Yup.string().nullable())) so the behavior (e.g., the blocks that
build
Yup.string().nullable().required(intl.formatMessage(messages.emailValidation))
or otherwise Yup.string().nullable()) is preserved but without using
then/otherwise keys, updating the corresponding conditions that reference
intl.formatMessage(messages.*) and any other message variants accordingly.
🧹 Nitpick comments (2)
src/components/Settings/SettingsNotifications/NotificationModal/TelegramModal.tsx (1)

116-133: Consider extracting the form-to-data mapping to reduce duplication.

The same object construction appears in both onSubmit (lines 117-132) and onSecondary (lines 158-173). Extracting a helper function would reduce duplication and ensure consistency.

♻️ Example refactor
// Add helper inside the Formik render function or outside the component
const buildTelegramData = (values: typeof initialValues): NotificationAgentTelegram => ({
  enabled: values.enabled,
  types: values.types,
  name: values.name,
  id: values.id,
  agent: values.agent,
  default: values.default,
  embedPoster: values.embedPoster,
  options: {
    botUsername: values.botUsername,
    botAPI: values.botAPI,
    chatId: values.chatId,
    messageThreadId: values.messageThreadId,
    sendSilently: values.sendSilently,
  },
});

// Then use:
// onSubmit: onSave(buildTelegramData(values))
// onSecondary: onTest(buildTelegramData(values))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/TelegramModal.tsx`
around lines 116 - 133, Extract the duplicated form-to-data mapping used in
onSubmit and onSecondary into a single helper (e.g., buildTelegramData) that
converts the Formik form values to the NotificationAgentTelegram shape; call
this helper from the onSubmit handler (currently creating the object passed to
onSave) and from the onSecondary/test handler (currently creating the object
passed to onTest) to eliminate duplication and keep mapping logic consistent
(referencing the onSubmit callback, onSecondary callback, and
NotificationAgentTelegram/initialValues shape).
src/components/Settings/SettingsNotifications/NotificationModal/EmailModal.tsx (1)

163-185: Consider extracting a shared form-values → payload mapper.

The payload construction is duplicated in both submit and test flows. A small helper would reduce maintenance drift when fields are added/changed.

Also applies to: 202-224

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/EmailModal.tsx`
around lines 163 - 185, The submit and test flows in EmailModal duplicate the
same form-values → payload construction (the object passed to onSave and the
similar object used in the test flow around lines ~202-224); extract a single
helper e.g. mapEmailFormValuesToPayload(values) that builds { enabled, name, id,
agent, default, embedPoster, options: { userEmailRequired, emailFrom, smtpHost,
smtpPort: Number(...), secure: values.encryption === 'implicit', ignoreTls:
values.encryption === 'none', requireTls: values.encryption === 'opportunistic',
authUser, authPass, allowSelfSigned, senderName, pgpPrivateKey, pgpPassword } }
and replace the inline object in the onSave call and the test call to use this
helper so both flows share the same mapping logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/lib/notifications/agents/webhook.ts`:
- Around line 129-134: The buildPayload method currently calls JSON.parse on
getSettings().options.jsonPayload without validation; wrap parsing in a
try/catch and validate the value first (check getSettings().options.jsonPayload
is a non-empty string) in buildPayload, then JSON.parse inside the try; on parse
failure either return a safe default object (e.g., {}) or rethrow a clearer
error via logging/exception with context (include the webhook id or type) before
calling parseKeys(parsedJSON, payload, type) so parseKeys always receives a
valid object.

In `@src/components/Common/ToggleSwitch/index.tsx`:
- Around line 43-48: The focus-ring utilities on the inner knob rely on a parent
with the Tailwind "group" class but the outer <span> in the ToggleSwitch
component (the element rendering the track/knob container) is missing it; update
the outer span's className to include "group" so the "group-focus:" styles on
the knob (the inner span using isToggled ? 'translate-x-5' : 'translate-x-0')
activate when the container receives keyboard focus, preserving accessible focus
styling.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/TelegramModal.tsx`:
- Around line 69-82: The chatId schema applies .matches(...) unconditionally
after the .when(...) block, so when chatId is optional it still rejects empty
string; update the .matches call for chatId to pass the option
excludeEmptyString: true (i.e., use .matches(/^-?\d+$/,
intl.formatMessage(messages.telegramValidationChatIdRequired), {
excludeEmptyString: true })) so empty values bypass the regex when the field is
optional while keeping the existing required/nullable logic intact.

---

Duplicate comments:
In
`@src/components/Settings/SettingsNotifications/NotificationModal/EmailModal.tsx`:
- Around line 79-85: Replace the object-form Yup.when(...) with the
callback-form in EmailModal.tsx for all conditional schema blocks (the five
occurrences that currently use .when('enabled', { is: true, then: ...,
otherwise: ... })); specifically, convert each to the function form
.when('enabled', (enabled, schema) => enabled ?
schema.concat(Yup.string().nullable().required(intl.formatMessage(messages.emailValidation)))
: schema.concat(Yup.string().nullable())) so the behavior (e.g., the blocks that
build
Yup.string().nullable().required(intl.formatMessage(messages.emailValidation))
or otherwise Yup.string().nullable()) is preserved but without using
then/otherwise keys, updating the corresponding conditions that reference
intl.formatMessage(messages.*) and any other message variants accordingly.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/TelegramModal.tsx`:
- Around line 83-96: The messageThreadId validation incorrectly forces
requiredness and always applies the regex: update the Yup schema for
messageThreadId so it is not .required() in the then branch (make it .nullable()
and optional) and move the .matches() into the conditional so it only applies
when a non-empty value is present (use .matches(regex, msg, {
excludeEmptyString: true }) or call .matches(...).nullable() inside the when so
empty strings are allowed); reference messageThreadId, the Yup .when(...) block,
the .matches(...) call and
intl.formatMessage(messages.telegramValidationMessageThreadId) when making the
change.

---

Nitpick comments:
In
`@src/components/Settings/SettingsNotifications/NotificationModal/EmailModal.tsx`:
- Around line 163-185: The submit and test flows in EmailModal duplicate the
same form-values → payload construction (the object passed to onSave and the
similar object used in the test flow around lines ~202-224); extract a single
helper e.g. mapEmailFormValuesToPayload(values) that builds { enabled, name, id,
agent, default, embedPoster, options: { userEmailRequired, emailFrom, smtpHost,
smtpPort: Number(...), secure: values.encryption === 'implicit', ignoreTls:
values.encryption === 'none', requireTls: values.encryption === 'opportunistic',
authUser, authPass, allowSelfSigned, senderName, pgpPrivateKey, pgpPassword } }
and replace the inline object in the onSave call and the test call to use this
helper so both flows share the same mapping logic.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/TelegramModal.tsx`:
- Around line 116-133: Extract the duplicated form-to-data mapping used in
onSubmit and onSecondary into a single helper (e.g., buildTelegramData) that
converts the Formik form values to the NotificationAgentTelegram shape; call
this helper from the onSubmit handler (currently creating the object passed to
onSave) and from the onSecondary/test handler (currently creating the object
passed to onTest) to eliminate duplication and keep mapping logic consistent
(referencing the onSubmit callback, onSecondary callback, and
NotificationAgentTelegram/initialValues shape).

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5a48498 and c8af502.

📒 Files selected for processing (12)
  • server/lib/notifications/agents/agent.ts
  • server/lib/notifications/agents/discord.ts
  • server/lib/notifications/agents/email.ts
  • server/lib/notifications/agents/gotify.ts
  • server/lib/notifications/agents/ntfy.ts
  • server/lib/notifications/agents/pushbullet.ts
  • server/lib/notifications/agents/pushover.ts
  • server/lib/notifications/agents/slack.ts
  • server/lib/notifications/agents/webhook.ts
  • src/components/Common/ToggleSwitch/index.tsx
  • src/components/Settings/SettingsNotifications/NotificationModal/EmailModal.tsx
  • src/components/Settings/SettingsNotifications/NotificationModal/TelegramModal.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • server/lib/notifications/agents/pushover.ts
  • server/lib/notifications/agents/gotify.ts

Comment thread server/lib/notifications/agents/webhook.ts
Comment thread src/components/Common/ToggleSwitch/index.tsx
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
server/lib/notifications/agents/webhook.ts (1)

130-131: ⚠️ Potential issue | 🟠 Major

JSON.parse() needs error handling for potentially empty or invalid stored webhook payloads.

The current buildPayload method at lines 130–131 calls JSON.parse() without error handling. While the learning indicates UI validation ensures valid JSON for new edits, pre-existing webhook configurations in the database might have empty jsonPayload strings (the default initialization value at server/lib/settings/index.ts:189). An empty string will cause JSON.parse('') to throw an unhandled error at runtime, silently failing webhook sends.

Add defensive error handling around JSON.parse(payloadString) to either use a fallback default payload or log a clear error when stored data is invalid.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/lib/notifications/agents/webhook.ts` around lines 130 - 131, The
buildPayload method currently calls
JSON.parse(this.getSettings().options.jsonPayload) without protection; wrap the
parse in a try/catch and handle empty or invalid payloadString: first check if
payloadString is non-empty (from this.getSettings().options.jsonPayload), then
attempt JSON.parse inside try, on failure log a clear error (use the existing
logger available to the class or this.logger/console.error) including the
invalid payloadString and fall back to a safe default payload (e.g., {} or a
minimal required shape) and assign it to parsedJSON so webhook sends continue
safely.
🧹 Nitpick comments (2)
seerr-api.yml (2)

3449-3473: Use PUT instead of POST for the /{instanceId} update endpoint.

The description says "Add or update" which is idempotent upsert semantics. REST conventions reserve PUT for idempotent operations on a specific resource, while POST is typically for creating new resources without a known identifier.

♻️ Suggested fix
-    post:
+    put:
       summary: Add or update given notification instance settings
       description: Add or Update given notification instance settings with the provided values.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@seerr-api.yml` around lines 3449 - 3473, Change the operation for the
/{instanceId} resource from POST to PUT to reflect idempotent upsert semantics:
replace the HTTP verb key "post" with "put" for the operation targeting
"/{instanceId}" and keep/update the summary/description to indicate "Add or
update" (or "Create or update") so the OpenAPI operation for
NotificationInstance upsert uses the PUT method and returns the same response
schema referencing NotificationInstance.

3396-3413: Consider returning 201 for resource creation and fix typo.

POST to create a new resource conventionally returns 201 Created. Also, "sucessfully" should be "successfully".

♻️ Suggested fix
       responses:
-        '200':
-          description: 'Notification instance was sucessfully added'
+        '201':
+          description: 'Notification instance was successfully added'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@seerr-api.yml` around lines 3396 - 3413, Update the POST operation that
creates a NotificationInstance to return HTTP 201 instead of 200 and correct the
response description typo: change the responses entry under the post for "Add a
notification instance" from '200' to '201' and update the description text from
"Notification instance was sucessfully added" to "Notification instance was
successfully added" (references: the post operation, responses, and the
NotificationInstance schema).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@seerr-api.yml`:
- Around line 3467-3469: The OpenAPI response description under the '200'
response contains a typo: "sucessfully" should be "successfully"; update the
description string for the '200' response (the responses block for the
notification add/update operation) so it reads "Notification instance was
successfully added or updated" to correct the spelling.
- Around line 3474-3488: The DELETE operation "Delete given notification
instance" currently declares a '200' response with no body; change its responses
to return HTTP 204 No Content (or define a response schema if a body is
required) by replacing the '200' response entry with a '204' response and ensure
no response body/schema is declared for this operation (look for the operation
summary "Delete given notification instance", parameter name instanceId and the
responses block to make the change).

---

Duplicate comments:
In `@server/lib/notifications/agents/webhook.ts`:
- Around line 130-131: The buildPayload method currently calls
JSON.parse(this.getSettings().options.jsonPayload) without protection; wrap the
parse in a try/catch and handle empty or invalid payloadString: first check if
payloadString is non-empty (from this.getSettings().options.jsonPayload), then
attempt JSON.parse inside try, on failure log a clear error (use the existing
logger available to the class or this.logger/console.error) including the
invalid payloadString and fall back to a safe default payload (e.g., {} or a
minimal required shape) and assign it to parsedJSON so webhook sends continue
safely.

---

Nitpick comments:
In `@seerr-api.yml`:
- Around line 3449-3473: Change the operation for the /{instanceId} resource
from POST to PUT to reflect idempotent upsert semantics: replace the HTTP verb
key "post" with "put" for the operation targeting "/{instanceId}" and
keep/update the summary/description to indicate "Add or update" (or "Create or
update") so the OpenAPI operation for NotificationInstance upsert uses the PUT
method and returns the same response schema referencing NotificationInstance.
- Around line 3396-3413: Update the POST operation that creates a
NotificationInstance to return HTTP 201 instead of 200 and correct the response
description typo: change the responses entry under the post for "Add a
notification instance" from '200' to '201' and update the description text from
"Notification instance was sucessfully added" to "Notification instance was
successfully added" (references: the post operation, responses, and the
NotificationInstance schema).

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c8af502 and b8cab84.

📒 Files selected for processing (2)
  • seerr-api.yml
  • server/lib/notifications/agents/webhook.ts

Comment thread seerr-api.yml Outdated
Comment thread seerr-api.yml Outdated
Comment on lines +3474 to +3488
delete:
summary: Delete given notification instance
description: Delete given notification instance.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookSettings'
parameters:
- in: path
name: instanceId
required: true
schema:
type: number
example: 0
responses:
'200':
description: 'Values were sucessfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookSettings'
/settings/notifications/webhook/test:
post:
summary: Test webhook settings
description: Sends a test notification to the webhook agent.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookSettings'
responses:
'204':
description: Test notification attempted
description: 'Notification instance was sucessfully deleted'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

DELETE endpoint should return 204 or define a response schema.

Currently returns 200 with no response body defined. For consistency with other delete endpoints in this spec (e.g., /blocklist/{tmdbId} returns 204), use 204 No Content.

🔧 Suggested fix
       responses:
-        '200':
-          description: 'Notification instance was sucessfully deleted'
+        '204':
+          description: 'Notification instance was successfully deleted'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
delete:
summary: Delete given notification instance
description: Delete given notification instance.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookSettings'
parameters:
- in: path
name: instanceId
required: true
schema:
type: number
example: 0
responses:
'200':
description: 'Values were sucessfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookSettings'
/settings/notifications/webhook/test:
post:
summary: Test webhook settings
description: Sends a test notification to the webhook agent.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookSettings'
responses:
'204':
description: Test notification attempted
description: 'Notification instance was sucessfully deleted'
delete:
summary: Delete given notification instance
description: Delete given notification instance.
tags:
- settings
parameters:
- in: path
name: instanceId
required: true
schema:
type: number
example: 0
responses:
'204':
description: 'Notification instance was successfully deleted'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@seerr-api.yml` around lines 3474 - 3488, The DELETE operation "Delete given
notification instance" currently declares a '200' response with no body; change
its responses to return HTTP 204 No Content (or define a response schema if a
body is required) by replacing the '200' response entry with a '204' response
and ensure no response body/schema is declared for this operation (look for the
operation summary "Delete given notification instance", parameter name
instanceId and the responses block to make the change).

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 1, 2026

This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.

@github-actions github-actions Bot added the merge conflict Cannot merge due to merge conflicts label Mar 1, 2026
@daddario11
Copy link
Copy Markdown

daddario11 commented Apr 17, 2026

Hey @Schrottfresser, took a look at the conflicts, here's what needs fixing:

  • Delete conflicts - upstream touched the old per-agent components but you deleted them. Just git rm all of them, your new unified system replaces them entirely.
  • server/lib/settings/index.ts - two things: upstream added a mergeSettings helper using mergeWith from lodash which is worth keeping (handles arrays better than plain merge), but also dumped all the type definitions inline that you already moved to interfaces/settings.ts - drop those. Also keep your singular notification setter, ignore upstream renaming it to notifications.
  • server/lib/notifications/agents/ntfy.ts - upstream added getSettings() and escapeMarkdown(). Drop the getSettings() override, your BaseAgent already does that. Keep escapeMarkdown(), your buildPayload already calls it anyway.
  • en.json - upstream added translation keys for the old components you deleted. Keep them deleted.
  • Lint - you have catch (e) in NotificationInstanceList.tsx (4x), NotificationModal/index.tsx (3x), and WebhookModal.tsx (1x) where e is never used. Change them all to just catch {} to match what upstream already did to the old components.

Everything else merges clean. The priority for ntfy and customHeaders for webhook that upstream added are already handled in your new agent code.

@Schrottfresser
Copy link
Copy Markdown
Contributor Author

Hey @Schrottfresser, took a look at the conflicts, here's what needs fixing:

* **Delete conflicts** - upstream touched the old per-agent components but you deleted them. Just `git rm` all of them, your new unified system replaces them entirely.

* **server/lib/settings/index.ts** - two things: upstream added a `mergeSettings` helper using `mergeWith` from lodash which is worth keeping (handles arrays better than plain `merge`), but also dumped all the type definitions inline that you already moved to `interfaces/settings.ts` - drop those. Also keep your singular `notification` setter, ignore upstream renaming it to `notifications`.

* **server/lib/notifications/agents/ntfy.ts** - upstream added `getSettings()` and `escapeMarkdown()`. Drop the `getSettings()` override, your `BaseAgent` already does that. Keep `escapeMarkdown()`, your `buildPayload` already calls it anyway.

* **en.json** - upstream added translation keys for the old components you deleted. Keep them deleted.

* **Lint** - you have `catch (e)` in `NotificationInstanceList.tsx` (4x), `NotificationModal/index.tsx` (3x), and `WebhookModal.tsx` (1x) where `e` is never used. Change them all to just `catch {}` to match what upstream already did to the old components.

Everything else merges clean. The priority for ntfy and customHeaders for webhook that upstream added are already handled in your new agent code.

Hi @daddario11 thanks for checking the conflicts. I hadn't enough time in the last few weeks to check the status and will merge in the next few days once again.

@daddario11
Copy link
Copy Markdown

Hey @Schrottfresser, took a look at the conflicts, here's what needs fixing:

* **Delete conflicts** - upstream touched the old per-agent components but you deleted them. Just `git rm` all of them, your new unified system replaces them entirely.

* **server/lib/settings/index.ts** - two things: upstream added a `mergeSettings` helper using `mergeWith` from lodash which is worth keeping (handles arrays better than plain `merge`), but also dumped all the type definitions inline that you already moved to `interfaces/settings.ts` - drop those. Also keep your singular `notification` setter, ignore upstream renaming it to `notifications`.

* **server/lib/notifications/agents/ntfy.ts** - upstream added `getSettings()` and `escapeMarkdown()`. Drop the `getSettings()` override, your `BaseAgent` already does that. Keep `escapeMarkdown()`, your `buildPayload` already calls it anyway.

* **en.json** - upstream added translation keys for the old components you deleted. Keep them deleted.

* **Lint** - you have `catch (e)` in `NotificationInstanceList.tsx` (4x), `NotificationModal/index.tsx` (3x), and `WebhookModal.tsx` (1x) where `e` is never used. Change them all to just `catch {}` to match what upstream already did to the old components.

Everything else merges clean. The priority for ntfy and customHeaders for webhook that upstream added are already handled in your new agent code.

Hi @daddario11 thanks for checking the conflicts. I hadn't enough time in the last few weeks to check the status and will merge in the next few days once again.

@Schrottfresser looking forward to it, have been hoping for this feature for a while!

@denisgabriel5
Copy link
Copy Markdown

Hi @daddario11, haven't had the chance to look through the code, but I was wondering if this PR adds the ability to set up 2 webhooks (send one notification to my server and the other to my friend's server). And from your screenshot I see that it's setting up an email server, I hope that's not necessary for webhooks, right?

Also, in the notification title or body, can the instance name or server name or even the URL of the Seerr be specified? An use case would be if I get 2 notifications: one from the Seerr hosted on my server and the other from the Seerr hosted on my friend's server. This way I can distinguish from which server they came.

@daddario11
Copy link
Copy Markdown

daddario11 commented Apr 24, 2026

Hey @denisgabriel5, just to clarify I'm not the one who opened this PR, that's @Schrottfresser. But based on looking at the code I can say: yes you can set up multiple ntfy instances for different servers, no email is not required, and the server name isn't currently included in the notification text (thanks @fallenbagel for the correction).

@fallenbagel
Copy link
Copy Markdown
Collaborator

Hey @denisgabriel5, just to clarify I'm not the one who opened this PR, that's @Schrottfresser. But based on looking at the code I can say: yes you can set up multiple ntfy instances for different servers, no email is not required, and the server name isn't currently included in the notification text though the click link will point to the right server. Best to confirm the specifics with @Schrottfresser, though.

Even right now in the current versions and old versions we had support for setting server name. applicationTitle is used when showing view in {applicationTitle} text when application url is added.

https://github.com/seerr-team/seerr/blob/develop/server%2Flib%2Fnotifications%2Fagents%2Ftelegram.ts#L159

@daddario11
Copy link
Copy Markdown

Hey @denisgabriel5, just to clarify I'm not the one who opened this PR, that's @Schrottfresser. But based on looking at the code I can say: yes you can set up multiple ntfy instances for different servers, no email is not required, and the server name isn't currently included in the notification text though the click link will point to the right server. Best to confirm the specifics with @Schrottfresser, though.

Even right now in the current versions and old versions we had support for setting server name. applicationTitle is used when showing view in {applicationTitle} text when application url is added.

https://github.com/seerr-team/seerr/blob/develop/server%2Flib%2Fnotifications%2Fagents%2Ftelegram.ts#L159

thank you for the correction @fallenbagel

@github-actions github-actions Bot added blocked:template and removed merge conflict Cannot merge due to merge conflicts labels Apr 26, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

♻️ Duplicate comments (2)
src/components/Settings/SettingsNotifications/NotificationModal/PushoverModal.tsx (1)

59-90: ⚠️ Potential issue | 🟠 Major

.matches() will reject the empty disabled state, and .when({ then }) trips Biome.

Two issues in NotificationsPushoverSchema:

  1. When the instance is disabled with no token, accessToken/userToken default to ''; .matches(/^[a-z\d]{30}$/i, ...) is then evaluated against the empty string and fails, blocking save. Add excludeEmptyString: true to both .matches() calls.
  2. .when('enabled', { is, then, otherwise }) triggers Biome's lint/suspicious/noThenProperty—use the callback form instead (already raised in DiscordModal/NtfyModal/EmailModal reviews).
🔧 Suggested fix (item 1)
       .matches(
         /^[a-z\d]{30}$/i,
-        intl.formatMessage(messages.pushoverValidationAccessTokenRequired)
+        {
+          message: intl.formatMessage(messages.pushoverValidationAccessTokenRequired),
+          excludeEmptyString: true,
+        }
       ),

(same for userToken)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/PushoverModal.tsx`
around lines 59 - 90, NotificationsPushoverSchema currently fails validation for
disabled instances because .matches(...) runs against empty strings and the
object .when('enabled', { then }) form triggers the linter; update both
accessToken and userToken validators to (1) use the callback form of when — e.g.
.when('enabled', (enabled, schema) => enabled ?
schema.nullable().required(intl.formatMessage(...)) : schema.nullable()) — and
(2) add excludeEmptyString: true to their .matches(...) calls so the empty ''
value is excluded from the regex check.
server/lib/notifications/index.ts (1)

204-227: ⚠️ Potential issue | 🔴 Critical

Guard missing-instance mutations before touching activeAgents.

If instanceId is unknown, unregisterAgent() will execute splice(-1, 1) and drop the last registered agent, while reregisterAgent() writes to this.activeAgents[-1] without replacing anything. Return early when instanceIndex === -1 and log a warning instead.

🛠️ Suggested fix
   public unregisterAgent = (instanceId: number) => {
     const instanceIndex = this.activeAgents.findIndex(
       (instance) => instance.id === instanceId
     );
 
+    if (instanceIndex === -1) {
+      logger.warn(
+        `Tried to unregister unknown notification agent instance id ${instanceId}`,
+        { label: 'Notifications' }
+      );
+      return;
+    }
+
     this.activeAgents.splice(instanceIndex, 1);
     logger.info(
       `Unregistered notification agent instance with id ${instanceId}`,
       { label: 'Notifications' }
     );
@@
   public reregisterAgent = (agent: NotificationAgent, instanceId: number) => {
     const instanceIndex = this.activeAgents.findIndex(
       (instance) => instance.id === instanceId
     );
 
+    if (instanceIndex === -1) {
+      logger.warn(
+        `Tried to reregister unknown notification agent instance id ${instanceId}`,
+        { label: 'Notifications' }
+      );
+      return;
+    }
+
     this.activeAgents[instanceIndex] = agent;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/lib/notifications/index.ts` around lines 204 - 227, The methods
unregisterAgent and reregisterAgent mutate activeAgents without checking that
the computed instanceIndex is valid; if findIndex returns -1 they will
incorrectly remove or overwrite the last element or write to [-1]. Fix both
functions by checking if instanceIndex === -1 and return early (no mutation)
while calling logger.warn (include the instanceId and label 'Notifications') to
indicate the unknown id, otherwise proceed with splice or assignment as
currently implemented.
🟡 Minor comments (13)
src/components/RequestModal/TvRequestModal.tsx-538-548 (1)

538-548: ⚠️ Potential issue | 🟡 Minor

disabled check is inconsistent with toggleAllSeasons gating when remaining === 0.

The disabled condition uses quota?.tv.remaining as a truthy check, so when remaining === 0 (limit set, nothing left) the toggle renders as enabled, yet toggleAllSeasons still short-circuits via (quota?.tv.remaining ?? 0) < unrequestedSeasons.length. Result: clickable-looking control that does nothing.

Align the predicate with toggleAllSeasons (and tighten the unrequestedSeasons.length > 0 edge while you're at it).

🔧 Proposed fix
                       <ToggleSwitch
                         isToggled={isAllSeasons()}
                         onToggle={() => toggleAllSeasons()}
                         disabled={
                           !!(
-                            quota?.tv.remaining &&
-                            quota.tv.limit &&
-                            quota.tv.remaining < unrequestedSeasons.length
+                            quota?.tv.limit &&
+                            (quota?.tv.remaining ?? 0) < unrequestedSeasons.length
                           )
                         }
                       />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/RequestModal/TvRequestModal.tsx` around lines 538 - 548, The
ToggleSwitch renders enabled when quota.tv.remaining === 0 but toggleAllSeasons
short-circuits in that case, so update the disabled predicate to match the
gating logic used by toggleAllSeasons: compute remaining as (quota?.tv.remaining
?? 0) and disable the control when remaining < unrequestedSeasons.length or when
unrequestedSeasons.length === 0; reference ToggleSwitch props, isAllSeasons(),
toggleAllSeasons, quota.tv.remaining, quota.tv.limit, and
unrequestedSeasons.length to locate and replace the current disabled expression.
server/interfaces/settings.ts-108-157 (1)

108-157: ⚠️ Potential issue | 🟡 Minor

Duplicate ProxySettings interface declaration.

ProxySettings is exported twice with identical shape (lines 108–117 and 148–157). TypeScript's declaration merging will accept this, but it's dead code that will silently cause issues if one definition drifts. Remove the duplicate.

♻️ Proposed cleanup
 export interface MainSettings {
   ...
   youtubeUrl: string;
 }

-export interface ProxySettings {
-  enabled: boolean;
-  hostname: string;
-  port: number;
-  useSsl: boolean;
-  user: string;
-  password: string;
-  bypassFilter: string;
-  bypassLocalAddresses: boolean;
-}
-
 export interface DnsCacheSettings {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/interfaces/settings.ts` around lines 108 - 157, The file declares the
ProxySettings interface twice; remove the duplicate declaration to avoid
accidental drift and confusion. Locate the two exported ProxySettings interfaces
(the ones with properties enabled, hostname, port, useSsl, user, password,
bypassFilter, bypassLocalAddresses) and delete one of them so only a single
export of ProxySettings remains; ensure any imports/uses still reference
ProxySettings unchanged and run type checks to confirm no regressions.
server/entity/User.ts-211-214 (1)

211-214: ⚠️ Potential issue | 🟡 Minor

Add validation before using default email instance to prevent silent failures.

retrieveDefaultNotificationInstanceSettings returns agentTemplates.email (with empty smtpHost and emailFrom) when no admin-configured default email instance exists. Both generatePassword and resetPassword pass this fallback template directly to PreparedEmail, which hands empty SMTP settings to nodemailer.createTransport. Attempts to send fail later with a generic "Failed to send..." log rather than a clear signal that email is not configured.

The codebase already uses a better pattern in usersettings.ts (checking instances.some() for a valid default before proceeding). Apply the same validation here.

🛡️ Suggested guard
-      const defaultEmailInstance = retrieveDefaultNotificationInstanceSettings(
-        NotificationAgentKey.EMAIL
-      ) as NotificationAgentEmail;
-      const email = new PreparedEmail(defaultEmailInstance);
+      const defaultEmailInstance = retrieveDefaultNotificationInstanceSettings(
+        NotificationAgentKey.EMAIL
+      ) as NotificationAgentEmail | undefined;
+      if (!defaultEmailInstance?.options?.smtpHost) {
+        logger.warn(
+          'No default email agent configured; skipping password email',
+          { label: 'User Management', email: this.email }
+        );
+        return;
+      }
+      const email = new PreparedEmail(defaultEmailInstance);

Apply the same fix to resetPassword (lines 252–255).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/entity/User.ts` around lines 211 - 214, The default email template
returned by
retrieveDefaultNotificationInstanceSettings(NotificationAgentKey.EMAIL) can be
an empty fallback and must be validated before creating PreparedEmail; update
generatePassword and resetPassword to check the returned defaultEmailInstance
for required SMTP settings (e.g., smtpHost and emailFrom or a simple isValid
check similar to usersettings.ts' instances.some()) and if invalid, log a clear
error and abort the email send flow instead of constructing PreparedEmail (which
passes empty settings to nodemailer.createTransport); apply the same guard in
both generatePassword and resetPassword and ensure the error message clearly
states email is not configured so callers can handle the failure early.
server/routes/settings/notification.ts-117-141 (1)

117-141: ⚠️ Potential issue | 🟡 Minor

Use 401 (or next({ status: 401 })) when req.user is missing.

req.user being absent indicates an authentication problem, not a server fault. Returning 500 (Line 120) misleads clients/monitoring. Prefer 401. The trailing message in Line 138 also includes raw req.body.agent in the response — typically harmless, but worth noting if input sanitization is a concern.

🔧 Suggested fix
   if (!req.user) {
     return next({
-      status: 500,
+      status: 401,
       message: 'User information is missing from the request.',
     });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/settings/notification.ts` around lines 117 - 141, Update the
error handling in the notificationRoutes.post('/test' ...) handler so that when
req.user is missing you call next with status 401 (authentication error) instead
of 500; locate the req.user check and change the error status to 401 and an
appropriate message. Also review the createAccordingNotificationAgent and
sendTestNotification branches: keep the existing 500 for a missing/invalid agent
returned by createAccordingNotificationAgent, and consider sanitizing or
avoiding echoing raw req.body.agent in the final next({ status: 500, message:
... }) error to prevent reflecting unsanitized input.
src/components/Settings/SettingsNotifications/NotificationModal/GotifyModal.tsx-99-103 (1)

99-103: ⚠️ Potential issue | 🟡 Minor

Inconsistent optional chaining on data / data.options.

Lines 99–101 access data.options.url/token/priority directly, but Line 102 uses data?.options.locale. The optional chain on data only is misleading — if data ever is undefined, lines 99–101 already crash, and Line 102 will still throw on .locale (the chain stops at data?.options, which is undefined, then .locale is fine — but the inconsistency suggests intent is unclear). Either drop the ?. (since data is asserted non-optional by the prop type) or extend it consistently:

♻️ Proposed cleanup
-        url: data.options.url,
-        token: data.options.token,
-        priority: data.options.priority,
-        locale: data?.options.locale ?? 'en',
+        url: data.options.url,
+        token: data.options.token,
+        priority: data.options.priority,
+        locale: data.options.locale ?? 'en',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/GotifyModal.tsx`
around lines 99 - 103, The object initialisation in GotifyModal.tsx mixes direct
access to data.options (url/token/priority) with an optional chain for locale
(data?.options.locale), which is inconsistent and misleading; update the code to
access locale the same way as the other fields — e.g., use data.options.locale
?? 'en' — or, if data/data.options may actually be undefined, add an explicit
guard (throw or early return) before constructing the object so all accesses can
safely use data.options; refer to the GotifyModal component and the object
literal that sets url, token, priority, locale to locate the change.
server/routes/settings/notification.ts-212-230 (1)

212-230: ⚠️ Potential issue | 🟡 Minor

Use 204 No Content for successful DELETE.

Line 229 returns 200 with an empty body, but conventional REST (and the project's other handlers — see Line 134) uses 204 for empty success responses. Also, as noted on the create handler, if settings.save() fails after splice/unregisterAgent, the in-memory state has already diverged from disk; consider persisting first.

🔧 Suggested fix
-  res.status(200).send();
+  res.status(204).send();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/settings/notification.ts` around lines 212 - 230, Change the
DELETE handler to return 204 No Content and avoid mutating disk/in-memory order:
compute a new instances array (e.g. newInstances = instances.filter(i => i.id
!== Number(req.params.id))), assign it to settings.notification.instances, call
await settings.save() and only after successful save call
notificationManager.unregisterAgent(Number(req.params.id)); finally respond with
res.status(204).send(); keep using notificationRoutes.delete, getSettings,
settings.save, notificationManager.unregisterAgent and avoid using
instances.splice before save.
src/components/Settings/SettingsNotifications/NotificationModal/TelegramModal.tsx-355-370 (1)

355-370: ⚠️ Potential issue | 🟡 Minor

error prop on NotificationTypeSelector will never display a message here.

The schema on Lines 59–92 doesn't define a validation rule for types, so errors.types is always undefined — the user will never see the "must select at least one notification type" feedback that PushbulletModal and GotifyModal surface. For consistency, use the same conditional as the other modals (and add a validationTypes message to this file's local messages):

♻️ Suggested fix
               <NotificationTypeSelector
                 currentTypes={values.enabled && values.types ? values.types : 0}
                 onUpdate={(newTypes) => {
                   setFieldValue('types', newTypes);
                   setFieldTouched('types');

                   if (newTypes) {
                     setFieldValue('enabled', true);
                   }
                 }}
                 error={
-                  errors.types && touched.types
-                    ? (errors.types as string)
+                  values.enabled && !values.types && touched.types
+                    ? intl.formatMessage(messages.validationTypes)
                     : undefined
                 }
               />

Add to messages:

validationTypes: 'You must select at least one notification type',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/TelegramModal.tsx`
around lines 355 - 370, The NotificationTypeSelector's error prop never shows
because this file's validation schema lacks a rule for "types" and there's no
local messages.validationTypes; update the validation schema in this file to
include a rule for "types" (matching the other modals) and add a
messages.validationTypes entry with the text "You must select at least one
notification type", then change the NotificationTypeSelector error conditional
to mirror PushbulletModal/GotifyModal (use errors.types && touched.types ?
messages.validationTypes or errors.types : undefined) so the validation message
displays correctly; reference NotificationTypeSelector, errors, touched,
messages, and the file's validation schema to locate the changes.
src/components/Settings/SettingsNotifications/NotificationModal/DiscordModal.tsx-191-225 (1)

191-225: ⚠️ Potential issue | 🟡 Minor

Wrong htmlFor on the Webhook URL label (a11y).

Line 192 uses htmlFor="name" for the Discord Webhook URL label, but the corresponding field's id is webhookUrl (line 213). Clicking the label currently focuses the (already-rendered above) instance Name input instead of the Webhook URL input.

🔧 Suggested fix
-              <label htmlFor="name" className="text-label">
+              <label htmlFor="webhookUrl" className="text-label">
                 {intl.formatMessage(messages.discordWebhookUrl)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/DiscordModal.tsx`
around lines 191 - 225, The label for the Discord Webhook URL uses
htmlFor="name" but the input Field has id="webhookUrl", causing the label to
focus the wrong control; update the label's htmlFor attribute in the
DiscordModal component so it matches the Field id (change htmlFor from "name" to
"webhookUrl") to restore correct accessible label association for the Field with
id "webhookUrl".
src/components/Settings/SettingsNotifications/NotificationModal/PushoverModal.tsx-53-57 (1)

53-57: ⚠️ Potential issue | 🟡 Minor

Sounds list never refreshes for the form's live token; also missing URL-encoding.

The SWR key is bound to the initial data.options.accessToken, not the live Formik values.accessToken. A user pasting/changing the application token in the form won't see the sounds list populate until they save and reopen the modal. Additionally, the token is interpolated into a query string without encodeURIComponent, which will break for any token containing reserved characters.

To make this reactive, lift the SWR call inside the Formik render-prop (or a child component) so it can read values.accessToken:

{({ values, ... }) => {
  const { data: soundsData } = useSWR<PushoverSound[]>(
    values.accessToken
      ? `/api/v1/settings/notifications/pushover/sounds?token=${encodeURIComponent(values.accessToken)}`
      : null
  );
  ...
}}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/PushoverModal.tsx`
around lines 53 - 57, The SWR call in PushoverModal is currently using the
static data.options.accessToken so the sounds list doesn't update as the user
edits the form and the token isn't URL-encoded; move the useSWR<PushoverSound[]>
call inside the Formik render-prop (or a child component that reads Formik's
values) so it uses values.accessToken, and build the key using
encodeURIComponent(values.accessToken) when present (or null otherwise) so the
sounds endpoint refreshes live and handles reserved characters.
server/lib/settings/index.ts-416-421 (2)

416-421: ⚠️ Potential issue | 🟡 Minor

userEmailRequired derivation ignores whether the email instance is enabled.

enablePushRegistration and emailEnabled correctly require instance.enabled, but userEmailRequired only checks instance.default && agent === EMAIL && options.userEmailRequired. A disabled default email instance that still has userEmailRequired: true will gate user-email collection on the public settings even though email is off, blocking signups/etc. for no reason.

🔧 Suggested fix
       userEmailRequired: this.notification.instances.some(
         (instance) =>
           instance.default &&
           instance.agent === NotificationAgentKey.EMAIL &&
+          instance.enabled &&
           instance.options.userEmailRequired
       ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/lib/settings/index.ts` around lines 416 - 421, The computation of
userEmailRequired on the public settings reads notification.instances but misses
checking instance.enabled, so a disabled default EMAIL instance with
options.userEmailRequired could incorrectly require emails; update the logic
that sets userEmailRequired to also require instance.enabled (same pattern used
by enablePushRegistration/emailEnabled) when iterating notification.instances
for NotificationAgentKey.EMAIL and options.userEmailRequired so disabled
instances do not gate user-email collection.

416-421: ⚠️ Potential issue | 🟡 Minor

Narrow the type of instance when accessing userEmailRequired on the loosely-typed options field.

instance.options is typed as Record<string, unknown> on the base NotificationAgentConfig. Although the code checks instance.agent === NotificationAgentKey.EMAIL, TypeScript does not narrow the type based on this runtime check alone. Add a type guard or cast to NotificationAgentEmail to ensure type safety when accessing instance.options.userEmailRequired:

instance.agent === NotificationAgentKey.EMAIL && 
(instance as NotificationAgentEmail).options.userEmailRequired

Or use a type predicate to create a proper type guard.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/lib/settings/index.ts` around lines 416 - 421, The code reads
instance.options.userEmailRequired on a loosely typed NotificationAgentConfig;
TypeScript won't narrow options based on the runtime agent check, so add a
proper narrowing before accessing userEmailRequired: either cast the instance to
NotificationAgentEmail or implement a type predicate that returns instance is
NotificationAgentEmail when instance.agent === NotificationAgentKey.EMAIL, then
use that narrowed type when reading options.userEmailRequired (references:
instance, userEmailRequired, NotificationAgentKey.EMAIL, NotificationAgentEmail,
NotificationAgentConfig, options).
src/components/Settings/SettingsNotifications/NotificationModal/index.tsx-66-103 (1)

66-103: ⚠️ Potential issue | 🟡 Minor

afterSave() runs on failure, closing the modal even when save/create fails.

Both onSave and onCreate invoke afterSave() from finally, so a failed POST still triggers the parent close/refresh flow alongside the error toast. The user loses their unsaved form data and has to start over. Consider only calling afterSave() from the try block (success path) and letting the catch surface the toast without dismissing the modal.

🔧 Suggested fix
   const onSave = async (submitData: NotificationAgentConfig) => {
     try {
       await axios.post(
         `/api/v1/settings/notification/${submitData.id}`,
         submitData
       );

       addToast(intl.formatMessage(messages.toastSaveSuccess), {
         appearance: 'success',
         autoDismiss: true,
       });
+      afterSave();
     } catch {
       addToast(intl.formatMessage(messages.toastSaveFail), {
         appearance: 'error',
         autoDismiss: true,
       });
-    } finally {
-      afterSave();
     }
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Settings/SettingsNotifications/NotificationModal/index.tsx`
around lines 66 - 103, The modal is being closed regardless of success because
afterSave() is called in the finally block of both onSave and onCreate; change
the flow so afterSave() is only invoked on the successful path: remove
afterSave() from the finally blocks in onSave and onCreate and instead call
afterSave() immediately after the success toast inside each try block (after the
axios.post and addToast success call), leaving the catch block to only show the
error toast so the modal stays open on failure.
src/components/Settings/SettingsNotifications/NotificationModal/NtfyModal.tsx-64-73 (1)

64-73: ⚠️ Potential issue | 🟡 Minor

Wrong validation message wired to the topic schema.

Line 73 calls .defined(intl.formatMessage(messages.ntfyValidationUrl)) on the topic field, but the message should be messages.ntfyValidationTopic. As-is, a missing topic surfaces "You must provide a valid URL" to users.

🔧 Suggested fix
-      .defined(intl.formatMessage(messages.ntfyValidationUrl)),
+      .defined(intl.formatMessage(messages.ntfyValidationTopic)),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/NtfyModal.tsx`
around lines 64 - 73, The Yup validation for the topic field (the topic schema
in NtfyModal.tsx) is using the wrong message: replace the
.defined(intl.formatMessage(messages.ntfyValidationUrl)) call on the topic
schema with .defined(intl.formatMessage(messages.ntfyValidationTopic)) so the
topic field surfaces the correct "topic" validation message while keeping the
existing .when('enabled') conditional (.nullable()/.required(...)) logic intact.
🧹 Nitpick comments (9)
server/routes/auth.test.ts (1)

90-122: Scope the notification.instances mutation in a try/finally.

If any of the assertions between Line 92 and Line 121 throws, the pushed instance leaks into subsequent tests in this file (and the same applies to the localLogin and user.email mutations). This matches the established convention for settings mutations in the test suite — capture prior state, mutate, exercise inside try, restore inside finally.

♻️ Suggested pattern
   it('includes userEmailRequired warning when email is required but invalid', async () => {
     const settings = getSettings();
-    settings.notification.instances.push({
-      default: true,
-      agent: NotificationAgentKey.EMAIL,
-      options: {
-        userEmailRequired: true,
-      },
-    } as NotificationAgentEmail);
-
-    // Change the user's email to something invalid
-    const userRepo = getRepository(User);
-    const user = await userRepo.findOneOrFail({
-      where: { email: 'admin@seerr.dev' },
-    });
-    user.email = 'not-an-email';
-    await userRepo.save(user);
-    ...
-    settings.notification.instances = [];
+    const priorInstances = [...settings.notification.instances];
+    settings.notification.instances.push({
+      default: true,
+      agent: NotificationAgentKey.EMAIL,
+      options: { userEmailRequired: true },
+    } as NotificationAgentEmail);
+    try {
+      // ... existing test body ...
+    } finally {
+      settings.notification.instances = priorInstances;
+    }
   });

Based on learnings, settings mutations in this test suite should be scoped to the narrowest possible window via try/finally rather than ad-hoc cleanup at the end of the test body.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/auth.test.ts` around lines 90 - 122, The test mutates global
state (settings.notification.instances, settings.main.localLogin and the
User.email) and should restore prior values in a try/finally to avoid leaking
into other tests: capture originals (e.g., const origInstances =
settings.notification.instances.slice(), const origLocalLogin =
settings.main.localLogin, const origEmail = user.email), perform the push,
user.email change and login/assertions inside a try block, and in finally
restore settings.notification.instances = origInstances,
settings.main.localLogin = origLocalLogin and reset user.email then await
userRepo.save(user) to persist the reverted email; reference getSettings(),
settings.notification.instances, settings.main.localLogin, userRepo and User to
locate the changes.
server/routes/settings/notification.ts (1)

33-43: findFirstFreeNotificationInstanceId has correct semantics but is O(n²).

The loop logic is correct (returns the smallest non-negative integer not present, or instances.length if [0..n-1] are all taken). For typical instance counts this is fine, but a Set-based lookup avoids the nested find:

♻️ Optional optimization
 const findFirstFreeNotificationInstanceId = () => {
   const instances = getSettings().notification.instances;
-
-  for (let i = 0; i < instances.length; ++i) {
-    if (!instances.find((instance) => instance.id === i)) {
-      return i;
-    }
-  }
-
-  return instances.length;
+  const usedIds = new Set(instances.map((i) => i.id));
+  for (let i = 0; i <= instances.length; ++i) {
+    if (!usedIds.has(i)) return i;
+  }
+  return instances.length;
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/settings/notification.ts` around lines 33 - 43,
findFirstFreeNotificationInstanceId is correct but does O(n²) work by calling
instances.find inside the loop; to fix, build a Set of existing ids from
getSettings().notification.instances (or map ids into a boolean/indexed map) and
then iterate i from 0 to instances.length checking set.has(i) to return the
first missing id (or instances.length if none), updating the function
findFirstFreeNotificationInstanceId to use the Set-based lookup for O(n)
complexity.
src/components/Settings/SettingsNotifications/NotificationModal/GotifyModal.tsx (1)

105-161: De-duplicate test/submit payload construction.

The mapping from values into a NotificationAgentGotify payload is duplicated between onSubmit (Lines 105–121) and onSecondary (Lines 145–161). A small buildPayload() helper inside the render scope keeps the two flows in sync as the schema evolves. Same suggestion applies to PushbulletModal/TelegramModal.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/GotifyModal.tsx`
around lines 105 - 161, The payload mapping from form values to the
NotificationAgentGotify shape is duplicated in the onSubmit handler and the
Modal secondary onSecondary callback (which calls onTest); extract a small
helper function (e.g., buildGotifyPayload(values): NotificationAgentGotify)
inside the render scope and call it from both onSubmit and onSecondary so they
share the same logic; ensure the helper maps enabled, types, name, id, agent,
default, embedPoster: true and options: { url: values.url, token: values.token,
priority: Number(values.priority), locale: values.locale } and use that helper
in place of the inline object in onSubmit and the onSecondary/onTest invocation.
src/components/Settings/SettingsNotifications/NotificationModal/PushbulletModal.tsx (1)

60-122: De-duplicate the test/submit payload construction.

The mapping from Formik values to NotificationAgentPushbullet is duplicated between onSubmit (Lines 72-83) and onSecondary (Lines 110-121). Extracting a small helper inside the render function avoids drift if the payload shape evolves (e.g., when adding more options later).

♻️ Suggested refactor
       {({
         errors,
         touched,
         isSubmitting,
         values,
         isValid,
         setFieldValue,
         setFieldTouched,
         handleSubmit,
       }) => {
+        const buildPayload = (): NotificationAgentPushbullet => ({
+          enabled: values.enabled,
+          types: values.types,
+          name: values.name,
+          id: values.id,
+          agent: values.agent,
+          default: values.default,
+          embedPoster: true,
+          options: {
+            accessToken: values.accessToken,
+            channelTag: values.channelTag,
+          },
+        });

Then both onSubmit and onSecondary can call buildPayload().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/PushbulletModal.tsx`
around lines 60 - 122, The payload mapping from Formik values to the
NotificationAgentPushbullet object is duplicated in the onSubmit handler and the
onSecondary test call; create a small helper function (e.g., buildPayload or
buildPushbulletPayload) inside the render scope that accepts the current Formik
values (or reads values closure) and returns the object shape used for
onSave/onTest (fields: enabled, types, name, id, agent, default, embedPoster:
true, options: { accessToken, channelTag }). Replace the inline objects in
onSubmit (which calls onSave) and onSecondary (which calls onTest) to call this
helper to avoid duplication and keep the payload construction centralized.
src/components/Settings/SettingsNotifications/NotificationModal/EmailModal.tsx (1)

448-460: rows="10" should be a number for the underlying <textarea>.

React's intrinsic <textarea> rows prop is typed as number. Passing the string "10" works at runtime due to HTML coercion but will surface as a TS error if SensitiveInput's prop typing is tightened. Prefer rows={10}.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/EmailModal.tsx`
around lines 448 - 460, The rows prop for the PGP private key textarea is passed
as a string ("10") which should be a number; update the SensitiveInput usage for
the pgpPrivateKey field (the component instance with id/name "pgpPrivateKey") to
pass rows as a numeric literal (rows={10}) so it matches the intrinsic textarea
typing and avoids future TypeScript errors.
src/components/Settings/SettingsNotifications/NotificationModal/NtfyModal.tsx (1)

362-379: Redundant truthy guard on values.types.

values.enabled && values.types ? values.types || 0 : 0 evaluates values.types || 0 only after values.types was already truthy in the ternary condition, so the || 0 is unreachable. Simplify to values.enabled && values.types ? values.types : 0.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/NtfyModal.tsx`
around lines 362 - 379, The currentTypes prop on NotificationTypeSelector uses a
redundant guard: replace the expression `values.enabled && values.types ?
values.types || 0 : 0` with the simplified `values.enabled && values.types ?
values.types : 0` (i.e., update the currentTypes prop in NtfyModal to directly
return values.types when enabled, otherwise 0) so the unreachable `|| 0` is
removed while preserving the same fallback behavior.
src/components/Settings/SettingsNotifications/NotificationModal/index.tsx (2)

105-136: Test toast flow assumes addToast invokes its callback synchronously.

The toast id is captured via the third-arg callback and immediately referenced post-await. react-toast-notifications v2 does invoke the callback synchronously, so this works today, but it's brittle—any future async-id implementation would leak the "sending…" toast (it's autoDismiss: false). A safer pattern is to call removeToast(toastId) only when defined and consider also clearing on onClose to prevent dangling toasts if the modal unmounts mid-request.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Settings/SettingsNotifications/NotificationModal/index.tsx`
around lines 105 - 136, The onTest function captures the toast id via addToast's
callback which assumes synchronous invocation; make it robust by storing the
toast id in a local state/ref or the synchronous return (instead of relying on
the third-arg callback), ensure removal happens in a finally block so
removeToast(toastId) is attempted regardless of success/failure, and attach an
onClose/onUnmount handler to clear the pending "sending…" toast if the modal
unmounts mid-request; update references to toastId, addToast, removeToast and
the onTest flow to implement these changes.

229-238: WebPush case uses NotificationAgentConfig while every other agent uses a provider-specific cast.

All other notification agents cast to their own interface (NotificationAgentDiscord, NotificationAgentEmail, etc.), but WebPush casts to the base NotificationAgentConfig. WebPushModalProps accepts the broader NotificationAgentConfig type, which differs from the pattern. For consistency, consider exporting a NotificationAgentWebPush interface and casting to it like the other agents.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Settings/SettingsNotifications/NotificationModal/index.tsx`
around lines 229 - 238, The WebPush branch is casting data to the broad
NotificationAgentConfig instead of a provider-specific type; define and export a
NotificationAgentWebPush interface (matching WebPushModalProps shape) and use it
consistently like the others: update WebPushModalProps to reference
NotificationAgentWebPush if needed and change the cast in the
NotificationAgentKey.WEBPUSH case to cast data as NotificationAgentWebPush
before passing to <WebPushModal /> so it mirrors other agents (e.g.,
NotificationAgentDiscord/Email) and keeps typing consistent.
server/lib/settings/index.ts (1)

109-211: types field is optional but pattern inconsistent across agent templates.

The types field on NotificationAgentConfig is optional (types?: number), so omitting it from email and webpush templates is not a TypeScript error. However, most other agents (discord, slack, telegram, pushbullet, pushover, webhook) explicitly set types: 0, while only email and webpush omit it. For consistency, either add types: 0 to email and webpush, or document why these agents intentionally differ.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/lib/settings/index.ts` around lines 109 - 211, The agent templates are
inconsistent: the optional NotificationAgentConfig.types field is set to 0 for
most agents but omitted for the email and webpush objects; update the email and
webpush config objects to include types: 0 for consistency (the objects
referenced are the email block using NotificationAgentKey.EMAIL and the webpush
block using NotificationAgentKey.WEBPUSH) so all agent templates follow the same
pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@seerr-api.yml`:
- Around line 1413-1415: The NotificationInstance schema currently includes an
id that is being reused for create/test/update request bodies, making id appear
client-writable; change the API models so the response schema
(NotificationInstance) retains id as readOnly and create a separate request
schema (e.g., NotificationInstanceCreate and NotificationInstanceUpdate) that
omits id, then update the operation request/response refs for endpoints like
/settings/notification/{instanceId} and the related create/test/update paths to
use the new request schemas; alternatively, if you prefer a minimal change, mark
the id property in NotificationInstance as readOnly to prevent it appearing
writable in generated SDKs and ensure request bodies reference the request-only
schema where applicable.
- Around line 1506-1517: Add the missing options.customHeaders property to the
webhook template schema so generated clients include the header list; update the
"options" object (used by the webhook template for /settings/notification) to
include customHeaders: type array with items being an object containing at least
"name" (string) and "value" (string) properties (and allow empty arrays/null as
appropriate), matching the UI/settings types that consume options.customHeaders.

In `@server/lib/overseerrMerge.ts`:
- Around line 143-148: The check against the new path
settings.notification.agentTemplates.email.options.senderName never sees legacy
"Overseerr" because Settings().load(undefined, true) skips migrations; fix by
either loading with migrations (call Settings().load(undefined, false) or remove
the raw=true) or, if you must skip migrations, detect and migrate from the
legacy key settings.notifications.agents.email.options.senderName whenever the
new path equals the default "Seerr" — copy the legacy value into
settings.notification.agentTemplates.email.options.senderName (and persist/save
the Settings) so the rebrand replacement actually runs.

In `@server/routes/settings/notification.ts`:
- Around line 87-210: Both POST '/' and POST '/:id' handlers currently persist
the incoming request without enforcing that only one instance per agent type has
default: true; when request.default === true, iterate
settings.notification.instances and set default = false for any instance with
the same agent before assigning instances[notificationInstanceIndex] = request
and saving. Update the logic in the routes that create/update agents (the
anonymous async handlers for notificationRoutes.post('/') and
notificationRoutes.post('/:id')) to perform this normalization prior to
settings.save(), and ensure the same normalization runs whether registering
(notificationManager.registerAgent) or reregistering
(notificationManager.reregisterAgent) so
retrieveDefaultNotificationInstanceSettings will see a single default per agent
type.
- Around line 87-115: The code registers the notification agent with
notificationManager.registerAgent before persisting the new instance via
settings.save, risking in-memory/disk divergence on save failure; update the
POST handler (the notificationRoutes.post function) to persist the new instance
into settings.instances and await settings.save() first, then call
notificationManager.registerAgent(createAccordingNotificationAgent(...)) only
after save succeeds, and for symmetry update the PUT (/:id) and DELETE (/:id)
handlers similarly—or alternatively, if you must register first, wrap
registerAgent in try/catch and call notificationManager.unregisterAgent (or roll
back the registration) when settings.save() throws so in-memory state is
consistent with persisted settings.
- Around line 157-210: The POST /:id handler currently allows creating instances
with arbitrary caller-supplied IDs and duplicates code for agent creation;
update the notificationRoutes.post handler to validate and sanitize
req.params.id (convert to Number, ensure !isNaN and id >= 0), then if the id is
outside the valid existing-range reject with 404 (or require clients to use POST
/ to create new instances) instead of creating a new entry at that id; also
hoist the createAccordingNotificationAgent(...) call and its null-check so you
only create the agent once and then choose between
notificationManager.registerAgent(...) or
notificationManager.reregisterAgent(...), and ensure you use
findFirstFreeNotificationInstanceId (or
settings.notification.findFirstFreeNotificationInstanceId) when creating a
genuinely new instance rather than trusting the caller-supplied id.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/EmailModal.tsx`:
- Around line 123-126: The PGP private key regex validator inside EmailModal's
validation schema (.matches(...)) is rejecting empty strings produced by Formik;
update that .matches call on the PGP private key field in the EmailModal
component to pass the option excludeEmptyString: true so empty string values are
treated as “no value” and validation is skipped when pgpPassword is empty,
preserving the conditional "no PGP" workflow.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/WebhookModal.tsx`:
- Around line 129-206: The schema's custom tests for webhookUrl and jsonPayload
run even when the webhook is disabled; update the 'valid-url' and
'validate-json' test functions inside NotificationsWebhookSchema to first check
this.parent.enabled and immediately return true if enabled is false, so the URL
and JSON validation only run when enabled is true; ensure you reference the
existing test names ('valid-url' for webhookUrl and 'validate-json' for
jsonPayload) and rely on this.parent.enabled to guard the checks.

---

Minor comments:
In `@server/entity/User.ts`:
- Around line 211-214: The default email template returned by
retrieveDefaultNotificationInstanceSettings(NotificationAgentKey.EMAIL) can be
an empty fallback and must be validated before creating PreparedEmail; update
generatePassword and resetPassword to check the returned defaultEmailInstance
for required SMTP settings (e.g., smtpHost and emailFrom or a simple isValid
check similar to usersettings.ts' instances.some()) and if invalid, log a clear
error and abort the email send flow instead of constructing PreparedEmail (which
passes empty settings to nodemailer.createTransport); apply the same guard in
both generatePassword and resetPassword and ensure the error message clearly
states email is not configured so callers can handle the failure early.

In `@server/interfaces/settings.ts`:
- Around line 108-157: The file declares the ProxySettings interface twice;
remove the duplicate declaration to avoid accidental drift and confusion. Locate
the two exported ProxySettings interfaces (the ones with properties enabled,
hostname, port, useSsl, user, password, bypassFilter, bypassLocalAddresses) and
delete one of them so only a single export of ProxySettings remains; ensure any
imports/uses still reference ProxySettings unchanged and run type checks to
confirm no regressions.

In `@server/lib/settings/index.ts`:
- Around line 416-421: The computation of userEmailRequired on the public
settings reads notification.instances but misses checking instance.enabled, so a
disabled default EMAIL instance with options.userEmailRequired could incorrectly
require emails; update the logic that sets userEmailRequired to also require
instance.enabled (same pattern used by enablePushRegistration/emailEnabled) when
iterating notification.instances for NotificationAgentKey.EMAIL and
options.userEmailRequired so disabled instances do not gate user-email
collection.
- Around line 416-421: The code reads instance.options.userEmailRequired on a
loosely typed NotificationAgentConfig; TypeScript won't narrow options based on
the runtime agent check, so add a proper narrowing before accessing
userEmailRequired: either cast the instance to NotificationAgentEmail or
implement a type predicate that returns instance is NotificationAgentEmail when
instance.agent === NotificationAgentKey.EMAIL, then use that narrowed type when
reading options.userEmailRequired (references: instance, userEmailRequired,
NotificationAgentKey.EMAIL, NotificationAgentEmail, NotificationAgentConfig,
options).

In `@server/routes/settings/notification.ts`:
- Around line 117-141: Update the error handling in the
notificationRoutes.post('/test' ...) handler so that when req.user is missing
you call next with status 401 (authentication error) instead of 500; locate the
req.user check and change the error status to 401 and an appropriate message.
Also review the createAccordingNotificationAgent and sendTestNotification
branches: keep the existing 500 for a missing/invalid agent returned by
createAccordingNotificationAgent, and consider sanitizing or avoiding echoing
raw req.body.agent in the final next({ status: 500, message: ... }) error to
prevent reflecting unsanitized input.
- Around line 212-230: Change the DELETE handler to return 204 No Content and
avoid mutating disk/in-memory order: compute a new instances array (e.g.
newInstances = instances.filter(i => i.id !== Number(req.params.id))), assign it
to settings.notification.instances, call await settings.save() and only after
successful save call notificationManager.unregisterAgent(Number(req.params.id));
finally respond with res.status(204).send(); keep using
notificationRoutes.delete, getSettings, settings.save,
notificationManager.unregisterAgent and avoid using instances.splice before
save.

In `@src/components/RequestModal/TvRequestModal.tsx`:
- Around line 538-548: The ToggleSwitch renders enabled when quota.tv.remaining
=== 0 but toggleAllSeasons short-circuits in that case, so update the disabled
predicate to match the gating logic used by toggleAllSeasons: compute remaining
as (quota?.tv.remaining ?? 0) and disable the control when remaining <
unrequestedSeasons.length or when unrequestedSeasons.length === 0; reference
ToggleSwitch props, isAllSeasons(), toggleAllSeasons, quota.tv.remaining,
quota.tv.limit, and unrequestedSeasons.length to locate and replace the current
disabled expression.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/DiscordModal.tsx`:
- Around line 191-225: The label for the Discord Webhook URL uses htmlFor="name"
but the input Field has id="webhookUrl", causing the label to focus the wrong
control; update the label's htmlFor attribute in the DiscordModal component so
it matches the Field id (change htmlFor from "name" to "webhookUrl") to restore
correct accessible label association for the Field with id "webhookUrl".

In
`@src/components/Settings/SettingsNotifications/NotificationModal/GotifyModal.tsx`:
- Around line 99-103: The object initialisation in GotifyModal.tsx mixes direct
access to data.options (url/token/priority) with an optional chain for locale
(data?.options.locale), which is inconsistent and misleading; update the code to
access locale the same way as the other fields — e.g., use data.options.locale
?? 'en' — or, if data/data.options may actually be undefined, add an explicit
guard (throw or early return) before constructing the object so all accesses can
safely use data.options; refer to the GotifyModal component and the object
literal that sets url, token, priority, locale to locate the change.

In `@src/components/Settings/SettingsNotifications/NotificationModal/index.tsx`:
- Around line 66-103: The modal is being closed regardless of success because
afterSave() is called in the finally block of both onSave and onCreate; change
the flow so afterSave() is only invoked on the successful path: remove
afterSave() from the finally blocks in onSave and onCreate and instead call
afterSave() immediately after the success toast inside each try block (after the
axios.post and addToast success call), leaving the catch block to only show the
error toast so the modal stays open on failure.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/NtfyModal.tsx`:
- Around line 64-73: The Yup validation for the topic field (the topic schema in
NtfyModal.tsx) is using the wrong message: replace the
.defined(intl.formatMessage(messages.ntfyValidationUrl)) call on the topic
schema with .defined(intl.formatMessage(messages.ntfyValidationTopic)) so the
topic field surfaces the correct "topic" validation message while keeping the
existing .when('enabled') conditional (.nullable()/.required(...)) logic intact.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/PushoverModal.tsx`:
- Around line 53-57: The SWR call in PushoverModal is currently using the static
data.options.accessToken so the sounds list doesn't update as the user edits the
form and the token isn't URL-encoded; move the useSWR<PushoverSound[]> call
inside the Formik render-prop (or a child component that reads Formik's values)
so it uses values.accessToken, and build the key using
encodeURIComponent(values.accessToken) when present (or null otherwise) so the
sounds endpoint refreshes live and handles reserved characters.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/TelegramModal.tsx`:
- Around line 355-370: The NotificationTypeSelector's error prop never shows
because this file's validation schema lacks a rule for "types" and there's no
local messages.validationTypes; update the validation schema in this file to
include a rule for "types" (matching the other modals) and add a
messages.validationTypes entry with the text "You must select at least one
notification type", then change the NotificationTypeSelector error conditional
to mirror PushbulletModal/GotifyModal (use errors.types && touched.types ?
messages.validationTypes or errors.types : undefined) so the validation message
displays correctly; reference NotificationTypeSelector, errors, touched,
messages, and the file's validation schema to locate the changes.

---

Duplicate comments:
In `@server/lib/notifications/index.ts`:
- Around line 204-227: The methods unregisterAgent and reregisterAgent mutate
activeAgents without checking that the computed instanceIndex is valid; if
findIndex returns -1 they will incorrectly remove or overwrite the last element
or write to [-1]. Fix both functions by checking if instanceIndex === -1 and
return early (no mutation) while calling logger.warn (include the instanceId and
label 'Notifications') to indicate the unknown id, otherwise proceed with splice
or assignment as currently implemented.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/PushoverModal.tsx`:
- Around line 59-90: NotificationsPushoverSchema currently fails validation for
disabled instances because .matches(...) runs against empty strings and the
object .when('enabled', { then }) form triggers the linter; update both
accessToken and userToken validators to (1) use the callback form of when — e.g.
.when('enabled', (enabled, schema) => enabled ?
schema.nullable().required(intl.formatMessage(...)) : schema.nullable()) — and
(2) add excludeEmptyString: true to their .matches(...) calls so the empty ''
value is excluded from the regex check.

---

Nitpick comments:
In `@server/lib/settings/index.ts`:
- Around line 109-211: The agent templates are inconsistent: the optional
NotificationAgentConfig.types field is set to 0 for most agents but omitted for
the email and webpush objects; update the email and webpush config objects to
include types: 0 for consistency (the objects referenced are the email block
using NotificationAgentKey.EMAIL and the webpush block using
NotificationAgentKey.WEBPUSH) so all agent templates follow the same pattern.

In `@server/routes/auth.test.ts`:
- Around line 90-122: The test mutates global state
(settings.notification.instances, settings.main.localLogin and the User.email)
and should restore prior values in a try/finally to avoid leaking into other
tests: capture originals (e.g., const origInstances =
settings.notification.instances.slice(), const origLocalLogin =
settings.main.localLogin, const origEmail = user.email), perform the push,
user.email change and login/assertions inside a try block, and in finally
restore settings.notification.instances = origInstances,
settings.main.localLogin = origLocalLogin and reset user.email then await
userRepo.save(user) to persist the reverted email; reference getSettings(),
settings.notification.instances, settings.main.localLogin, userRepo and User to
locate the changes.

In `@server/routes/settings/notification.ts`:
- Around line 33-43: findFirstFreeNotificationInstanceId is correct but does
O(n²) work by calling instances.find inside the loop; to fix, build a Set of
existing ids from getSettings().notification.instances (or map ids into a
boolean/indexed map) and then iterate i from 0 to instances.length checking
set.has(i) to return the first missing id (or instances.length if none),
updating the function findFirstFreeNotificationInstanceId to use the Set-based
lookup for O(n) complexity.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/EmailModal.tsx`:
- Around line 448-460: The rows prop for the PGP private key textarea is passed
as a string ("10") which should be a number; update the SensitiveInput usage for
the pgpPrivateKey field (the component instance with id/name "pgpPrivateKey") to
pass rows as a numeric literal (rows={10}) so it matches the intrinsic textarea
typing and avoids future TypeScript errors.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/GotifyModal.tsx`:
- Around line 105-161: The payload mapping from form values to the
NotificationAgentGotify shape is duplicated in the onSubmit handler and the
Modal secondary onSecondary callback (which calls onTest); extract a small
helper function (e.g., buildGotifyPayload(values): NotificationAgentGotify)
inside the render scope and call it from both onSubmit and onSecondary so they
share the same logic; ensure the helper maps enabled, types, name, id, agent,
default, embedPoster: true and options: { url: values.url, token: values.token,
priority: Number(values.priority), locale: values.locale } and use that helper
in place of the inline object in onSubmit and the onSecondary/onTest invocation.

In `@src/components/Settings/SettingsNotifications/NotificationModal/index.tsx`:
- Around line 105-136: The onTest function captures the toast id via addToast's
callback which assumes synchronous invocation; make it robust by storing the
toast id in a local state/ref or the synchronous return (instead of relying on
the third-arg callback), ensure removal happens in a finally block so
removeToast(toastId) is attempted regardless of success/failure, and attach an
onClose/onUnmount handler to clear the pending "sending…" toast if the modal
unmounts mid-request; update references to toastId, addToast, removeToast and
the onTest flow to implement these changes.
- Around line 229-238: The WebPush branch is casting data to the broad
NotificationAgentConfig instead of a provider-specific type; define and export a
NotificationAgentWebPush interface (matching WebPushModalProps shape) and use it
consistently like the others: update WebPushModalProps to reference
NotificationAgentWebPush if needed and change the cast in the
NotificationAgentKey.WEBPUSH case to cast data as NotificationAgentWebPush
before passing to <WebPushModal /> so it mirrors other agents (e.g.,
NotificationAgentDiscord/Email) and keeps typing consistent.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/NtfyModal.tsx`:
- Around line 362-379: The currentTypes prop on NotificationTypeSelector uses a
redundant guard: replace the expression `values.enabled && values.types ?
values.types || 0 : 0` with the simplified `values.enabled && values.types ?
values.types : 0` (i.e., update the currentTypes prop in NtfyModal to directly
return values.types when enabled, otherwise 0) so the unreachable `|| 0` is
removed while preserving the same fallback behavior.

In
`@src/components/Settings/SettingsNotifications/NotificationModal/PushbulletModal.tsx`:
- Around line 60-122: The payload mapping from Formik values to the
NotificationAgentPushbullet object is duplicated in the onSubmit handler and the
onSecondary test call; create a small helper function (e.g., buildPayload or
buildPushbulletPayload) inside the render scope that accepts the current Formik
values (or reads values closure) and returns the object shape used for
onSave/onTest (fields: enabled, types, name, id, agent, default, embedPoster:
true, options: { accessToken, channelTag }). Replace the inline objects in
onSubmit (which calls onSave) and onSecondary (which calls onTest) to call this
helper to avoid duplication and keep the payload construction centralized.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

Comment thread seerr-api.yml
Comment thread seerr-api.yml
Comment on lines +143 to 148
if (
settings.notification.agentTemplates.email.options.senderName ===
'Overseerr'
) {
settings.notification.agentTemplates.email.options.senderName = 'Seerr';
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether anything migrates the legacy senderName path into the new agentTemplates path,
# and check how Settings.load() handles the legacy notifications.agents shape.
rg -nP -C3 "notifications\.agents\.email|agentTemplates\.email|senderName"
rg -nP -C5 "mergeSettings|class Settings\b|public (?:async )?load\b"

Repository: seerr-team/seerr

Length of output: 50372


🏁 Script executed:

# First, get the full overseerrMerge.ts file to understand context
wc -l server/lib/overseerrMerge.ts

Repository: seerr-team/seerr

Length of output: 93


🏁 Script executed:

# Get the full overseerrMerge.ts file
cat -n server/lib/overseerrMerge.ts

Repository: seerr-team/seerr

Length of output: 50372


🏁 Script executed:

# Look for Settings class and load method
fd -t f "settings" "server/lib/settings/" | head -10

Repository: seerr-team/seerr

Length of output: 124


🏁 Script executed:

# Search for migration or merge logic between old and new paths
rg -n "notifications\.agents\." server/lib/ --max-count 20

Repository: seerr-team/seerr

Length of output: 484


🏁 Script executed:

# Search specifically in settings-related files for migration
rg -n "agentTemplates|notification\." server/lib/settings/ -A 2 -B 2

Repository: seerr-team/seerr

Length of output: 1345


🏁 Script executed:

# Find the Settings class definition and load method
head -300 server/lib/settings/index.ts | tail -100

Repository: seerr-team/seerr

Length of output: 2516


🏁 Script executed:

# Search for Settings class and async load method
rg -n "class Settings|async load" server/lib/settings/index.ts -A 10 | head -80

Repository: seerr-team/seerr

Length of output: 635


🏁 Script executed:

# Look at migrations directory to see all migrations
fd -t f . server/lib/settings/migrations/ | sort

Repository: seerr-team/seerr

Length of output: 541


🏁 Script executed:

# Check if there's a migration that handles notifications.agents → agentTemplates
rg -n "notifications.agents|agentTemplates" server/lib/settings/migrations/ -B 2 -A 5

Repository: seerr-team/seerr

Length of output: 917


🏁 Script executed:

# Get the full Settings.load method to see what raw parameter does
sed -n '499,600p' server/lib/settings/index.ts

Repository: seerr-team/seerr

Length of output: 2302


🏁 Script executed:

# Look for where migrations are applied
rg -n "migration" server/lib/settings/index.ts -i | head -20

Repository: seerr-team/seerr

Length of output: 480


🏁 Script executed:

# Check how the settings are parsed from JSON - look for JSON parsing
rg -n "JSON.parse|readFileSync" server/lib/settings/index.ts -A 3 -B 1 | head -40

Repository: seerr-team/seerr

Length of output: 418


🏁 Script executed:

# Look for any notification/agent structure migration
rg -n "notifications|agentTemplates" server/lib/settings/migrations/ -l

Repository: seerr-team/seerr

Length of output: 114


🏁 Script executed:

# Find and examine the migrator file
cat server/lib/settings/migrator.ts

Repository: seerr-team/seerr

Length of output: 3293


🏁 Script executed:

# Also check if there's a migration for notification structure changes
rg -n "notification" server/lib/settings/migrations/ -l

Repository: seerr-team/seerr

Length of output: 114


🏁 Script executed:

# Verify that load with raw=true skips migrations by checking the exact flow
sed -n '516,530p' server/lib/settings/index.ts

Repository: seerr-team/seerr

Length of output: 502


🏁 Script executed:

# Double-check what happens when raw=true - confirm it doesn't call runMigrations
rg -n "raw" server/lib/settings/index.ts -B 2 -A 2

Repository: seerr-team/seerr

Length of output: 640


🏁 Script executed:

# Search all migrations to confirm none handle notifications.agents → agentTemplates conversion
cat server/lib/settings/migrations/*.ts | grep -i "notification\|agent" || echo "No notification migrations found"

Repository: seerr-team/seerr

Length of output: 219


🏁 Script executed:

# Verify the default value for senderName in new structure
rg -n "senderName.*Seerr" server/lib/settings/index.ts

Repository: seerr-team/seerr

Length of output: 99


Legacy senderName is never migrated to the new path because migrations are skipped.

The code calls Settings().load(undefined, true) with raw = true, which bypasses all migrations (line 516: if (data && !raw) evaluates to false). Additionally, there is no migration that converts settings.notifications.agents.email.options.senderName (old Overseerr path) to settings.notification.agentTemplates.email.options.senderName (new path). Since the new path defaults to 'Seerr' (per server/lib/settings/index.ts:124), this check will never match 'Overseerr' for real Overseerr installations, making the rebrand replacement a no-op.

Either apply migrations before the check, or read from the legacy path if the new path contains the default value.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/lib/overseerrMerge.ts` around lines 143 - 148, The check against the
new path settings.notification.agentTemplates.email.options.senderName never
sees legacy "Overseerr" because Settings().load(undefined, true) skips
migrations; fix by either loading with migrations (call
Settings().load(undefined, false) or remove the raw=true) or, if you must skip
migrations, detect and migrate from the legacy key
settings.notifications.agents.email.options.senderName whenever the new path
equals the default "Seerr" — copy the legacy value into
settings.notification.agentTemplates.email.options.senderName (and persist/save
the Settings) so the rebrand replacement actually runs.

Comment thread server/routes/settings/notification.ts
Comment on lines +87 to +115
notificationRoutes.post('/', async (req, res, next) => {
const settings = getSettings();
const instances = settings.notification.instances;

const notificationInstanceId = findFirstFreeNotificationInstanceId();

const request = req.body;
request.id = notificationInstanceId;

const notificationAgent = createAccordingNotificationAgent(
request,
notificationInstanceId
);

if (!notificationAgent) {
return next({
status: 500,
message: 'A valid agent is missing from the request.',
});
}

notificationManager.registerAgent(notificationAgent);

const notificationInstanceIndex = instances.length;
instances[notificationInstanceIndex] = request;
await settings.save();

res.status(200).json(instances[notificationInstanceIndex]);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Agent registered before persistence — partial-failure leaves inconsistent state.

If notificationManager.registerAgent(notificationAgent) succeeds but settings.save() throws on Line 112, the manager has a live agent that isn't reflected in persisted settings. On the next process restart the registration is lost, but until then the in-memory state diverges from disk. Consider persisting first and registering only after a successful save (or rolling back the registration in a catch).

🛡️ Suggested ordering
   notificationManager.registerAgent(notificationAgent);

   const notificationInstanceIndex = instances.length;
-  instances[notificationInstanceIndex] = request;
-  await settings.save();
+  instances[notificationInstanceIndex] = request;
+  try {
+    await settings.save();
+  } catch (e) {
+    notificationManager.unregisterAgent(notificationInstanceId);
+    instances.splice(notificationInstanceIndex, 1);
+    return next({ status: 500, message: 'Failed to persist notification instance.' });
+  }

The same concern applies to the POST /:id and DELETE /:id handlers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/settings/notification.ts` around lines 87 - 115, The code
registers the notification agent with notificationManager.registerAgent before
persisting the new instance via settings.save, risking in-memory/disk divergence
on save failure; update the POST handler (the notificationRoutes.post function)
to persist the new instance into settings.instances and await settings.save()
first, then call
notificationManager.registerAgent(createAccordingNotificationAgent(...)) only
after save succeeds, and for symmetry update the PUT (/:id) and DELETE (/:id)
handlers similarly—or alternatively, if you must register first, wrap
registerAgent in try/catch and call notificationManager.unregisterAgent (or roll
back the registration) when settings.save() throws so in-memory state is
consistent with persisted settings.

Comment thread server/routes/settings/notification.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support multiple notification agent instances More than one Discord Agent for notifications

5 participants