Skip to content

feat(notifications): added a custom notify email field, managed account now get mail#2365

Open
JackW6809 wants to merge 3 commits intoseerr-team:developfrom
JackW6809:custom-notify-email
Open

feat(notifications): added a custom notify email field, managed account now get mail#2365
JackW6809 wants to merge 3 commits intoseerr-team:developfrom
JackW6809:custom-notify-email

Conversation

@JackW6809
Copy link
Contributor

@JackW6809 JackW6809 commented Feb 1, 2026

Description

I manage all my Plex Users accounts, I have my own domain that I make the users accounts from on Plex. However this means that because the accounts are synced into Seerr from Plex the email field is pre-filled and non-changeable users do not receive updates on their requests.

I did not use AI to create or help me with this PR

How Has This Been Tested?

I added the changes and in the UI I sent a test e-mail notification with no custom notification and the synced original email receives the test email no problem. Then setting the custom notify e-mail and sending another test notification it then sends to the custom notify e-mail.

Additionally, I have ensured the e-mail persists on service restarts and page refreshes. E-mail validation is also required on that field if it has some text in it.

The environment is a x64 system, running the DB from SQLite3. All generated from Develop branch and tested the database migration using the guidance in Section 4.

Screenshots / Logs (if applicable)

Screenshot 2026-02-01 at 10 14 25 pm Screenshot 2026-02-01 at 10 15 16 pm Screenshot 2026-02-01 at 10 15 35 pm Screenshot 2026-02-01 at 10 16 58 pm Screenshot 2026-02-01 at 10 17 25 pm Screenshot 2026-02-01 at 10 17 29 pm Screenshot 2026-02-01 at 10 18 03 pm Screenshot 2026-02-01 at 10 18 20 pm Screenshot 2026-02-01 at 10 27 45 pm Screenshot 2026-02-01 at 10 26 15 pm Screenshot 2026-02-01 at 10 26 19 pm

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)

Summary by CodeRabbit

  • New Features
    • Optional “custom notification email” allowing a separate address for notifications (falls back to account email).
  • User Interface
    • Setting exposed across all notification preference forms with validation and explanatory tip.
  • Notifications
    • Emails and chat notifications may include a “Play on [Media Server]” action when available; notifications use the custom email when provided.
  • Localization
    • Added translation keys and validation messages for the new field.

@JackW6809 JackW6809 requested a review from a team as a code owner February 1, 2026 22:42
@JackW6809 JackW6809 changed the title Custom notify email feat(e-mail notification changes) Feb 1, 2026
@JackW6809 JackW6809 changed the title feat(e-mail notification changes) feat(e-mail notification changes): added a custom notify email field, managed account now get mail Feb 1, 2026
@JackW6809
Copy link
Contributor Author

All new and existing tests passed.

Is that the GH Actions? Thanks :)

@JackW6809 JackW6809 force-pushed the custom-notify-email branch from 177cccb to adbc4b5 Compare February 2, 2026 17:17
@fallenbagel fallenbagel changed the title feat(e-mail notification changes): added a custom notify email field, managed account now get mail feat(notifications): added a custom notify email field, managed account now get mail Feb 5, 2026
@JackW6809 JackW6809 force-pushed the custom-notify-email branch 10 times, most recently from 0fe41f5 to e13a6cd Compare February 13, 2026 22:04
@JackW6809 JackW6809 force-pushed the custom-notify-email branch 2 times, most recently from 9ab456a to 15cc20f Compare February 15, 2026 10:40
@coderabbitai
Copy link

coderabbitai bot commented Feb 15, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an optional notifyEmail field across API schema, DB entity and migrations, backend routes and email/notification agents, and frontend notification forms/validation; notification sending now prefers notifyEmail over the account email when present.

Changes

Cohort / File(s) Summary
API Schema & Interfaces
seerr-api.yml, server/interfaces/api/userSettingsInterfaces.ts
Add optional notifyEmail (nullable, format: email) to UserSettingsNotifications schema and response interface.
Database Entity & Migrations
server/entity/UserSettings.ts, server/migration/postgres/1769971931384-CustomNotifyEmailMigration.ts, server/migration/sqlite/1769971922216-CustomNotifyEmailMigration.ts
Add notifyEmail column/property to UserSettings entity and provide Postgres/SQLite migrations to add/remove and migrate the column (large SQLite migration).
Backend Routes & Notification Agents
server/routes/user/usersettings.ts, server/lib/notifications/agents/email.ts, server/lib/notifications/agents/slack.ts, server/lib/notifications/agents/telegram.ts
Persist and return notifyEmail via GET/POST; notification agents derive `userEmail = settings?.notifyEmail
Frontend Notification Forms & Validation
src/components/UserProfile/UserSettings/UserNotificationSettings/...
UserNotificationsDiscord.tsx, UserNotificationsEmail.tsx, UserNotificationsPushbullet.tsx, UserNotificationsPushover.tsx, UserNotificationsTelegram.tsx, UserNotificationsWebPush/index.tsx
Include notifyEmail in form initial values and submission payloads; Email settings UI adds an input, i18n keys, and validation for notifyEmail.
Templates & Translations
server/templates/email/media-request/html.pug, src/i18n/locale/en.json
Email template optionally renders a "Play on {mediaServerName}" link when media server URL present; add i18n keys notifyEmail, notifyEmailTip, and validationEmail.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant Frontend as Frontend
    participant API as API
    participant DB as Database
    participant EmailAgent as EmailAgent
    participant Provider as EmailProvider

    User->>Frontend: View notification settings
    Frontend->>API: GET /api/v1/user/:id/settings/notifications
    API->>DB: SELECT user_settings
    DB-->>API: user_settings (includes notifyEmail)
    API-->>Frontend: Return settings
    User->>Frontend: Submit updated settings (includes notifyEmail)
    Frontend->>API: POST /api/v1/user/:id/settings/notifications { notifyEmail, ... }
    API->>DB: INSERT/UPDATE user_settings.notifyEmail
    DB-->>API: OK
    Note over API,EmailAgent: Sending notification flow
    API->>EmailAgent: sendNotification(payload, user, settings)
    EmailAgent->>EmailAgent: userEmail = settings?.notifyEmail || user.email
    EmailAgent->>EmailAgent: validate(userEmail)
    EmailAgent->>Provider: send email to userEmail
    Provider-->>EmailAgent: delivery result
    EmailAgent-->>API: report result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A tiny hop for settings new,
I tuck a mail where pings come through,
From form to DB and outbound flight,
My whiskers twitch at every light,
Hooray — a threaded hop of blue!

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main feature: adding a custom notify email field for managed accounts. It clearly conveys the primary change without being vague or misleading.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into develop

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
server/lib/notifications/agents/slack.ts (1)

71-89: Extract shared helpers to reduce duplication and improve type safety.

These two helper functions (getAvailableMediaServerName and getAvailableMediaServerUrl) are duplicated nearly verbatim in telegram.ts. Consider extracting them to a shared utility module (e.g., server/lib/notifications/utils.ts).

Additionally, using (payload.media as any) bypasses type checking. If mediaUrl and mediaUrl4k are valid properties on the media type, consider properly typing them; otherwise, a type guard or dedicated interface would be safer.

♻️ Proposed shared utility

Create a new file server/lib/notifications/mediaServerUtils.ts:

import { MediaServerType } from '@server/constants/server';

export function getMediaServerName(mediaServerType: MediaServerType): string {
  switch (mediaServerType) {
    case MediaServerType.EMBY:
      return 'Emby';
    case MediaServerType.PLEX:
      return 'Plex';
    default:
      return 'Jellyfin';
  }
}

export function getMediaServerUrl(
  media: { mediaUrl?: string; mediaUrl4k?: string } | undefined,
  wants4k?: boolean
): string | undefined {
  const url4k = media?.mediaUrl4k;
  const url = media?.mediaUrl;
  return (wants4k ? (url4k ?? url) : (url ?? url4k)) || undefined;
}

Then import and use in both slack.ts and telegram.ts.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@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

🤖 Fix all issues with AI agents
In `@server/migration/sqlite/1769971922216-CustomNotifyEmailMigration.ts`:
- Around line 6-172: The migration's up() method (class
1769971922216-CustomNotifyEmailMigration) was auto-generated and needlessly
recreates many tables and duplicates user_push_subscription; instead, change
up() to only add the notifyEmail column to user_settings (use ALTER TABLE
"user_settings" ADD COLUMN "notifyEmail" varchar) and remove all temporary_*
table creation/inserts/renames and the duplicated
temporary_user_push_subscription blocks (references: up(),
"temporary_user_settings", "temporary_user_push_subscription", "user_settings",
"user_push_subscription"); also avoid changing datetime default expressions
(leave existing datetime('now') usage intact) so the migration only performs the
single schema change and keeps the down() reverse logic consistent.

In `@src/i18n/locale/en.json`:
- Around line 1476-1477: The JSON i18n tip string for
components.UserProfile.UserSettings.UserNotificationSettings.notifyEmailTip
mixes pronouns and mis-capitalizes "Email": update the value to use a consistent
pronoun and lowercase "email" (e.g., change "Email address to send notifications
to if you don't want to send notifications to their login Email" to something
like "Email address to send notifications to if you don't want to use the
account login email"), ensuring you edit the notifyEmailTip key (and leave
notifyEmail key unchanged).
🧹 Nitpick comments (2)
seerr-api.yml (1)

1942-1944: Add format: email for better schema validation.

This improves tooling and client-side validation without changing behavior.

♻️ Suggested schema tweak
         notifyEmail:
           type: string
+          format: email
           nullable: true
server/lib/notifications/agents/email.ts (1)

223-234: Use the already-computed userEmail variable instead of duplicating the expression.

The userEmail variable is computed at lines 223-225 but not used in the buildMessage call. The same expression is repeated at lines 231-232.

♻️ Proposed fix
           const userEmail =
             payload.notifyUser.settings?.notifyEmail ||
             payload.notifyUser.email;
           if (validator.isEmail(userEmail, { require_tld: false })) {
             await email.send(
               this.buildMessage(
                 type,
                 payload,
-                payload.notifyUser.settings?.notifyEmail ||
-                  payload.notifyUser.email,
+                userEmail,
                 payload.notifyUser.displayName
               )
             );

Comment on lines +6 to +172
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "UQ_6427d07d9a171a3a1ab87480005"`);
await queryRunner.query(
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
);
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
);
await queryRunner.query(
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, "notifyEmail" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId" FROM "user_settings"`
);
await queryRunner.query(`DROP TABLE "user_settings"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
);
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
await queryRunner.query(
`CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "watchlist"`
);
await queryRunner.query(`DROP TABLE "watchlist"`);
await queryRunner.query(
`ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
);
await queryRunner.query(
`CREATE TABLE "temporary_issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "issueId" integer, CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "issue_comment"`
);
await queryRunner.query(`DROP TABLE "issue_comment"`);
await queryRunner.query(
`ALTER TABLE "temporary_issue_comment" RENAME TO "issue_comment"`
);
await queryRunner.query(
`CREATE TABLE "temporary_issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "issue"`
);
await queryRunner.query(`DROP TABLE "issue"`);
await queryRunner.query(`ALTER TABLE "temporary_issue" RENAME TO "issue"`);
await queryRunner.query(
`CREATE TABLE "temporary_override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP))`
);
await queryRunner.query(
`INSERT INTO "temporary_override_rule"("id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt") SELECT "id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt" FROM "override_rule"`
);
await queryRunner.query(`DROP TABLE "override_rule"`);
await queryRunner.query(
`ALTER TABLE "temporary_override_rule" RENAME TO "override_rule"`
);
await queryRunner.query(
`CREATE TABLE "temporary_season_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestId" integer, CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a" FOREIGN KEY ("requestId") REFERENCES "media_request" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_season_request"("id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId" FROM "season_request"`
);
await queryRunner.query(`DROP TABLE "season_request"`);
await queryRunner.query(
`ALTER TABLE "temporary_season_request" RENAME TO "season_request"`
);
await queryRunner.query(
`CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest" FROM "media_request"`
);
await queryRunner.query(`DROP TABLE "media_request"`);
await queryRunner.query(
`ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
);
await queryRunner.query(
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
);
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
);
await queryRunner.query(
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "user"`
);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
await queryRunner.query(
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, "blacklistedTags" varchar, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags" FROM "blacklist"`
);
await queryRunner.query(`DROP TABLE "blacklist"`);
await queryRunner.query(
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
);
await queryRunner.query(
`CREATE TABLE "temporary_season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaId" integer, "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId", "status4k") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId", "status4k" FROM "season"`
);
await queryRunner.query(`DROP TABLE "season"`);
await queryRunner.query(
`ALTER TABLE "temporary_season" RENAME TO "season"`
);
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "media"`
);
await queryRunner.query(`DROP TABLE "media"`);
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
await queryRunner.query(
`CREATE TABLE "temporary_discover_slider" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" integer NOT NULL, "order" integer NOT NULL, "isBuiltIn" boolean NOT NULL DEFAULT (0), "enabled" boolean NOT NULL DEFAULT (1), "title" varchar, "data" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP))`
);
await queryRunner.query(
`INSERT INTO "temporary_discover_slider"("id", "type", "order", "isBuiltIn", "enabled", "title", "data", "createdAt", "updatedAt") SELECT "id", "type", "order", "isBuiltIn", "enabled", "title", "data", "createdAt", "updatedAt" FROM "discover_slider"`
);
await queryRunner.query(`DROP TABLE "discover_slider"`);
await queryRunner.query(
`ALTER TABLE "temporary_discover_slider" RENAME TO "discover_slider"`
);
await queryRunner.query(
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
);
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
);
}
Copy link

@coderabbitai coderabbitai bot Feb 15, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check recent SQLite migrations and identify the file structure
fd -e ts . server/migration/sqlite --exec basename {} \; | sort | tail -10
echo "---"
# Get line count to understand scope
wc -l server/migration/sqlite/1769971922216-CustomNotifyEmailMigration.ts

Repository: seerr-team/seerr

Length of output: 539


🏁 Script executed:

#!/bin/bash
# Read the full migration file to verify the claims
cat -n server/migration/sqlite/1769971922216-CustomNotifyEmailMigration.ts | head -200

Repository: seerr-team/seerr

Length of output: 24159


🏁 Script executed:

#!/bin/bash
# Check the down() method to assess rollback complexity
cat -n server/migration/sqlite/1769971922216-CustomNotifyEmailMigration.ts | tail -100

Repository: seerr-team/seerr

Length of output: 12700


Migration scope extends well beyond the notifyEmail feature and contains redundant operations.

This migration recreates 12 tables (user_push_subscription, user_settings, watchlist, issue_comment, issue, override_rule, season_request, media_request, user, blacklist, season, media, discover_slider) when only the notifyEmail column addition to user_settings is the actual feature change. The extensive table recreation increases execution time and failure risk.

Additionally, user_push_subscription is recreated three times within the up() method: first at lines 8–17, again identically at lines 90–98 (redundant), and finally at lines 162–171 with the unique constraint. These duplicate operations suggest the migration was auto-generated by TypeORM synchronization without cleanup.

Datetime defaults are also systematically changed from datetime('now') to CURRENT_TIMESTAMP across multiple tables, though the down() method properly restores the original format, ensuring reversibility.

The down() method is comprehensive and reverses all operations, mitigating rollback risk. However, test both up() and down() paths thoroughly before production deployment, particularly with existing user data.

🤖 Prompt for AI Agents
In `@server/migration/sqlite/1769971922216-CustomNotifyEmailMigration.ts` around
lines 6 - 172, The migration's up() method (class
1769971922216-CustomNotifyEmailMigration) was auto-generated and needlessly
recreates many tables and duplicates user_push_subscription; instead, change
up() to only add the notifyEmail column to user_settings (use ALTER TABLE
"user_settings" ADD COLUMN "notifyEmail" varchar) and remove all temporary_*
table creation/inserts/renames and the duplicated
temporary_user_push_subscription blocks (references: up(),
"temporary_user_settings", "temporary_user_push_subscription", "user_settings",
"user_push_subscription"); also avoid changing datetime default expressions
(leave existing datetime('now') usage intact) so the migration only performs the
single schema change and keeps the down() reverse logic consistent.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is an automated script that is generated - I think this can be safely ignored

Copy link

Choose a reason for hiding this comment

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

@JackW6809, you're correct that TypeORM auto-generates these migrations. However, auto-generated migrations often include unintended schema changes or redundant operations, especially when the ORM detects drift between entity definitions and the database state.

In this case, the migration recreates 12 tables and repeats the user_push_subscription table creation three times within the same up() method (lines 8-17, 90-98, and 162-171), when only adding the notifyEmail column to user_settings is actually necessary for this feature. This increases migration execution time and the risk of issues during deployment or rollback.

While it's ultimately your call as the maintainer, manually refining the migration to a single ALTER TABLE "user_settings" ADD COLUMN "notifyEmail" varchar statement would make it safer and faster. The comprehensive down() method you have is excellent for reversibility.

If you've tested both directions with production-like data and are confident in the current approach, I'll defer to your judgment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I will leave this down to the repo maintainers to decide on not me.

Copy link

Choose a reason for hiding this comment

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

@JackW6809, that's perfectly reasonable—thank you for clarifying! Since you're contributing this PR and the maintainers will make the final call on the migration approach, I'll leave this discussion thread open for them to review when they get to it.

Appreciate your work on this feature! 🐰

JackW6809 and others added 3 commits February 15, 2026 14:55
…nt now get mail

Some people, like me, like to manage peoples Plex account so all they need to worry about is signing
in with provided username and password. The problem with this and Seerr is that Seerr assumes that
people want to recieve mail on the email field. This is prefilled and non-changeable if users are
synced from Plex you cannot change it to the users actual email. In my Plex environment I use my own
domain for users login but they will never get mail from that, this now resolves that issue.

BREAKING CHANGE: Database Migration

Make null rather than empty string

Remove Debug Code

feat(e-mail notification changes): added a custom notify email field, managed account now get mail

Some people, like me, like to manage peoples Plex account so all they need to worry about is signing
in with provided username and password. The problem with this and Seerr is that Seerr assumes that
people want to recieve mail on the email field. This is prefilled and non-changeable if users are
synced from Plex you cannot change it to the users actual email. In my Plex environment I use my own
domain for users login but they will never get mail from that, this now resolves that issue.

BREAKING CHANGE: Database Migration

fix(en.json): update Translations

Update translations file due to failed GH Action
Fixed bugs in new code to ensure notification email is validated, also fixed spelling mistake I made
in the API
…puting, update api validation

I have updated the new verbiage I added to ensure it is in the current users context and not the
admins. Re-used a previously declared variable instead of re-computing it. Updated the acceptable
format for the API call made.

BREAKING CHANGE: API Updated, Notification Call Updated
@JackW6809 JackW6809 force-pushed the custom-notify-email branch 2 times, most recently from cd94f06 to 71cf6b0 Compare February 15, 2026 16:23
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.

1 participant