Skip to content

FEATURE: per-topic unsubscribe option in emails#2

Open
ShashankFC wants to merge 1 commit into
email-notifications-enhancementfrom
topic-email-management
Open

FEATURE: per-topic unsubscribe option in emails#2
ShashankFC wants to merge 1 commit into
email-notifications-enhancementfrom
topic-email-management

Conversation

@ShashankFC

@ShashankFC ShashankFC commented Jan 5, 2026

Copy link
Copy Markdown
Collaborator

Test 2

Summary by CodeRabbit

Release Notes

  • New Features
    • Users can now unsubscribe from topic notifications through a dedicated management page, allowing them to stop receiving notification alerts for specific topics.
    • Notification emails now include direct unsubscribe links, providing quick access to manage notification preferences.

✏️ Tip: You can customize this high-level summary in your review settings.


Replicated from ai-code-review-evaluation/discourse-coderabbit#2

@ShashankFC

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jan 13, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jan 13, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduces a topic unsubscribe feature enabling users to disable notifications for specific topics via a dedicated unsubscribe page. Adds frontend routes, controllers, views, and templates; backend controller action and model method; email integration updates; localization strings; and test coverage.

Changes

Cohort / File(s) Summary
Frontend Unsubscribe Module
app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6, app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6, app/assets/javascripts/discourse/views/topic-unsubscribe.js.es6, app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs
New unsubscribe feature: route loads topic view data and clears notification text; controller returns translated stop notifications message; view adds CSS class; template renders notification state UI and topic-notifications-button component.
Frontend Route Infrastructure
app/assets/javascripts/discourse/routes/app-route-map.js.es6, app/assets/javascripts/discourse/routes/topic-from-params.js.es6, app/assets/javascripts/discourse/components/dropdown-button.js.es6
Route registration for new unsubscribe path; refactored topic-from-params with improved method syntax and var-to-const conversions; conditional title rendering in dropdown-button.
Frontend Styling
app/assets/stylesheets/common/base/topic.scss
New .topic-unsubscribe CSS block with nested notification-options styling.
Backend Topic Control
app/controllers/topics_controller.rb, app/models/topic.rb, app/models/topic_user.rb
New unsubscribe controller action toggling TopicUser notification levels; new unsubscribe_url model method; formatting adjustments in topic_user.
Email Integration
app/mailers/user_notifications.rb, lib/email/message_builder.rb, app/views/email/notification.html.erb
Added unsubscribe_url to email context; refactored respond_instructions assignment; restructured notification email template with unsubscribe link in footer; template_args formatting updates.
Routing and Localization
config/routes.rb, config/locales/client.en.yml, config/locales/server.en.yml
New unsubscribe and summary routes (both slugged and non-slugged); topic.unsubscribe locale block with stop_notifications and change_notification_state; updated unsubscribe_link with additional paragraph.
Tests
spec/components/email/message_builder_spec.rb
Updated MessageBuilder initialization to accept unsubscribe_url keyword argument.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Email
    participant Client as Browser/Client
    participant Route as Route Handler
    participant Controller as Topics Controller
    participant Model as Topic/TopicUser
    participant DB as Database
    
    User->>Email: Receives notification with unsubscribe link
    User->>Client: Clicks unsubscribe link
    Client->>Route: GET /t/:slug/:id/unsubscribe
    Route->>Route: Load topic view data via PostStream
    Route->>Controller: setupController with topic
    Controller->>Model: Get topic details
    Model->>DB: Query topic and notification state
    DB-->>Model: Return topic data
    Controller->>Client: Render unsubscribe confirmation page
    User->>Client: Submit notification preference change
    Client->>Controller: POST unsubscribe action
    Controller->>Model: Toggle TopicUser notification_level
    Model->>DB: Update notification setting
    DB-->>Model: Confirm update
    Controller->>Client: Redirect to show response
    Client-->>User: Display updated topic
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hop! Skip! A new path appears,
Unsubscribe links calm all fears,
Topics tamed, notifications cease,
Users find their perfect peace!
~CodeRabbit

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding a per-topic unsubscribe feature in notification emails. It directly reflects the primary purpose of the changeset.

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

✨ Finishing touches
  • 📝 Generate docstrings

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/assets/javascripts/discourse/routes/app-route-map.js.es6 (1)

8-13: Move topicUnsubscribe route definition inside the topic resource as a nested route.

The standalone topicUnsubscribe route at /t/:slug/:id/unsubscribe won't be reachable because the topic resource (matching /t/:slug/:id) will intercept the request first. In Ember's router, the resource only recognizes its explicitly defined nested routes (fromParams and fromParamsNear), so a request to /unsubscribe would have no matching nested route and fail to route correctly.

Define topicUnsubscribe inside the topic resource's function block instead:

Suggested fix
this.resource('topic', { path: '/t/:slug/:id' }, function() {
  this.route('fromParams', { path: '/' });
  this.route('fromParamsNear', { path: '/:nearPost' });
  this.route('unsubscribe', { path: '/unsubscribe' });
});
🤖 Fix all issues with AI agents
In @app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6:
- Around line 5-7: Rename the misspelled method stopNotificiationsText to
stopNotificationsText in the controller (update the function name and its
property dependency string from "model.fancyTitle" as needed) and update the
template reference(s) in unsubscribe.hbs to use stopNotificationsText so both
sides match; search for any other usages of stopNotificiationsText and replace
them with stopNotificationsText to keep calls and bindings consistent.

In @app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6:
- Around line 4-10: The model hook calls PostStream.loadTopicView without
handling rejections; add a .catch handler on the promise returned by
PostStream.loadTopicView(params.id) to handle errors, call topic.updateFromJson
in the .then as before, and in the .catch redirect the user to an appropriate
error route (e.g., this.transitionTo('not-found') or another centralized error
page) so failures (missing topic, permissions, network) are handled gracefully.

In @app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs:
- Line 3: The template uses a misspelled property name `stopNotificiationsText`
— rename it to `stopNotificationsText` in the template (and update any
corresponding references) and remove the triple-brace rendering to prevent XSS:
replace the `{{{...}}}` usage with safe rendering by using double braces
`{{stopNotificationsText}}` unless you explicitly need HTML; if HTML is
required, ensure the controller sanitizes `fancyTitle` (or other user-provided
parts) before composing `stopNotificationsText` so only trusted HTML is output.

In @app/assets/javascripts/discourse/views/topic-unsubscribe.js.es6:
- Around line 1-3: This file exports a Discourse.View subclass that only sets
classNames ("topic-unsubscribe"); remove the unnecessary view by deleting or not
exporting the Discourse.View.extend in topic-unsubscribe.js.es6 and instead
apply the "topic-unsubscribe" CSS class directly in the relevant template (or
component) where the view was used; if other modules import this symbol, replace
those imports/uses to reference the template/component with the class applied
(or keep a very small passthrough module that documents future expansion) so no
empty View subclass remains.

In @app/controllers/topics_controller.rb:
- Around line 98-116: The unsubscribe action currently calls TopicUser.find_by
which can return nil and cause a NoMethodError when accessing
tu.notification_level; change this to use
TopicUser.find_or_initialize_by(user_id: current_user.id, topic_id:
params[:topic_id]) (or explicitly nil-check tu and initialize a new TopicUser)
so tu is always an object before reading/writing notification_level, set the
appropriate default attributes on the initialized record (e.g., user_id,
topic_id) and then save! as currently done before calling perform_show_response.

In @app/mailers/user_notifications.rb:
- Line 295: post.topic.unsubscribe_url can raise NoMethodError if post.topic is
nil; update the code that builds the mail payload in UserNotifications
(user_notifications.rb) to use nil-safe navigation and a sensible fallback: get
the topic into a local variable (e.g., topic = post.topic), set unsubscribe_url
using safe navigation (topic&.unsubscribe_url) and if nil supply a fallback URL
or nil-safe value (for example a generic unsubscribe path or a URL built from
post.topic_id), and ensure any helpers used (unsubscribe_url generator) handle a
nil topic gracefully.

In @app/views/email/notification.html.erb:
- Line 10: The h4 element currently uses an invalid class attribute value
'.previous-discussion' which includes a leading dot; update the class on the h4
(the element rendering t "user_notifications.previous_discussion") to remove the
dot so it reads class="previous-discussion" so CSS selectors targeting
.previous-discussion will apply correctly.

In @config/locales/client.en.yml:
- Around line 983-987: The translation uses raw HTML with
<strong>{{title}}</strong> and is rendered with triple-brace
stopNotificiationsText, while I18n.interpolate() (used by I18n.t()) does not
escape values; fix by ensuring model.fancyTitle is HTML-escaped before
interpolation (or update I18n.interpolate() to escape interpolation values by
default) so unsafe input cannot inject HTML/JS; update the controller that
supplies model.fancyTitle (or the I18n.t() call that produces
stopNotificiationsText) to pass an escaped title, or change the interpolation
implementation to escape values prior to replacing {{title}}.
🧹 Nitpick comments (4)
app/models/topic_user.rb (1)

60-66: create_lookup can be simplified with index_by, if you want.
Current code is fine; optional readability tweak: topic_users.to_a.index_by(&:topic_id).

lib/email/message_builder.rb (1)

61-68: Address RuboCop warning: assignment in condition.

Line 62 uses assignment within the if condition. While this is a valid Ruby idiom, wrapping it in parentheses clarifies intent and silences the linter warning.

Proposed fix
-        if response_instructions = @template_args[:respond_instructions]
+        if (response_instructions = @template_args[:respond_instructions])
spec/components/email/message_builder_spec.rb (1)

167-182: LGTM, but consider adding test coverage for unsubscribe_url usage.

The test correctly adds the unsubscribe_url parameter, but only verifies the existing List-Unsubscribe header and user_preferences_url in body behavior. Consider adding an assertion that the unsubscribe_url value is actually included in the template_args or rendered output.

it "includes the unsubscribe_url in template_args" do
  expect(message_with_unsubscribe.template_args[:unsubscribe_url]).to eq("/t/1234/unsubscribe")
end
app/controllers/topics_controller.rb (1)

27-28: Add trailing comma after the last array element.

Per RuboCop style convention, multiline arrays should have a trailing comma after the last element for cleaner diffs.

Suggested fix
                                          :bookmark,
-                                         :unsubscribe]
+                                         :unsubscribe,]
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 56abd24 and 6669a2d.

📒 Files selected for processing (18)
  • app/assets/javascripts/discourse/components/dropdown-button.js.es6
  • app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6
  • app/assets/javascripts/discourse/routes/app-route-map.js.es6
  • app/assets/javascripts/discourse/routes/topic-from-params.js.es6
  • app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6
  • app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs
  • app/assets/javascripts/discourse/views/topic-unsubscribe.js.es6
  • app/assets/stylesheets/common/base/topic.scss
  • app/controllers/topics_controller.rb
  • app/mailers/user_notifications.rb
  • app/models/topic.rb
  • app/models/topic_user.rb
  • app/views/email/notification.html.erb
  • config/locales/client.en.yml
  • config/locales/server.en.yml
  • config/routes.rb
  • lib/email/message_builder.rb
  • spec/components/email/message_builder_spec.rb
🧰 Additional context used
🧬 Code graph analysis (5)
app/models/topic.rb (4)
app/models/post.rb (2)
  • url (342-348)
  • url (350-352)
app/models/category.rb (1)
  • url (392-404)
app/serializers/similar_topic_serializer.rb (1)
  • url (14-16)
app/models/notification.rb (1)
  • url (90-94)
app/mailers/user_notifications.rb (2)
app/models/notification.rb (1)
  • post (96-100)
app/models/topic.rb (1)
  • unsubscribe_url (719-721)
app/models/topic_user.rb (1)
lib/distributed_cache.rb (1)
  • publish (64-69)
app/controllers/topics_controller.rb (3)
app/models/topic.rb (1)
  • unsubscribe_url (719-721)
app/serializers/listable_topic_serializer.rb (1)
  • notification_level (64-66)
app/models/topic_user.rb (1)
  • notification_levels (19-21)
lib/email/message_builder.rb (1)
lib/discourse.rb (1)
  • base_url (211-213)
🪛 RuboCop (1.82.1)
app/controllers/topics_controller.rb

[convention] 28-28: Put a comma after the last item of a multiline array.

(Style/TrailingCommaInArrayLiteral)


[convention] 98-116: Assignment Branch Condition size for unsubscribe is too high. [<4, 31, 6> 31.83/23]

(Metrics/AbcSize)

lib/email/message_builder.rb

[warning] 62-62: Use == if you meant to do a comparison or wrap the expression in parentheses to indicate you meant to assign in a condition.

(Lint/AssignmentInCondition)

🔇 Additional comments (17)
app/assets/javascripts/discourse/components/dropdown-button.js.es6 (1)

27-30: Conditional rendering logic is correct.

The guard prevents rendering an empty <h4> element when no title is provided, which is a sensible improvement. Both notifications-button and pinned-button components (the only subclasses of DropdownButton) set title: '' and never modify it, so this conditional will prevent unnecessary empty elements.

Note: The direct string concatenation pattern exists elsewhere in this file as well, but it is pre-existing and not introduced by this change.

app/models/topic_user.rb (3)

8-13: Scope formatting is fine; keep the chained .where readable.
No semantic concerns here.


112-114: MessageBus publishes are consistent across all locations.

All three occurrences in topic_user.rb (lines 112–114, 193–195, 218–219) publish with the same payload structure: { notification_level_change: ... }. JavaScript subscribers in topic.js.es6 (lines 585–591) correctly subscribe to /topic/:id and expect the notification_level_change key, confirming the client expects this payload shape.


120-131: I cannot verify this review comment because the repository is inaccessible. While the code snippet shown contains what appears to be a clear logic error on line 122 (assigning topic instead of user to user_id), I cannot confirm:

  • Whether this is the actual current code in the repository
  • How the method is called and what argument types are passed
  • Whether this bug path is exercised in practice
  • The actual impact or severity

Manual verification of the codebase and call sites is required to definitively assess this issue.

app/models/topic.rb (1)

719-721: The implementation is correct. The route shape is exactly t/:slug/:topic_id/unsubscribe and t/:topic_id/unsubscribe (per config/routes.rb), and Topic#unsubscribe_url correctly expands to the full URL via #{url}/unsubscribe. No token or query parameter is needed—the unsubscribe action relies on the current user context. The method is properly used in mailer notifications and handles absolute URLs appropriately for emails.

Likely an incorrect or invalid review comment.

config/locales/server.en.yml (1)

1852-1856: No email breakage risk—the only call site already passes unsubscribe_url.

The concern about missing unsubscribe_url interpolation is unfounded. A single call site in app/mailers/user_notifications.rb (line 294-295) sets add_unsubscribe_link: true and immediately provides unsubscribe_url: post.topic.unsubscribe_url in the same options block. No other mailers use add_unsubscribe_link, and rejection mailer explicitly disallows it. This was a coordinated feature release ("FEATURE: per-topic unsubscribe option in emails"), so the i18n key and call sites were updated together.

Likely an incorrect or invalid review comment.

app/assets/javascripts/discourse/routes/topic-from-params.js.es6 (2)

5-16: LGTM! Clean refactor to ES6 conventions.

The updates to use ES6 shorthand method syntax and const declarations improve code clarity. The const self = this pattern is appropriate here for the .then() callback context.


28-30: LGTM!

Converting post-lookup variables to const is appropriate since these values are not reassigned.

app/assets/stylesheets/common/base/topic.scss (1)

67-77: LGTM!

The scoped styles appropriately customize the notification dropdown behavior within the unsubscribe context. The float: none and bottom: initial overrides ensure proper layout for the dropdown in this specific view.

app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs (1)

6-6: LGTM!

The translation helper and component binding are correctly implemented.

app/mailers/user_notifications.rb (1)

310-310: LGTM!

Clean refactor from multi-line conditional to a single-line assignment with guard clause.

lib/email/message_builder.rb (2)

24-28: LGTM!

Clean formatting with trailing comma—follows Ruby best practices for multi-line hashes and makes future additions cleaner.


34-38: LGTM!

The refactored conditional assignment is cleaner while preserving the original behavior.

app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 (1)

12-15: LGTM!

The afterModel hook appropriately clears notification text for the unsubscribe context, and didTransition correctly enables the footer.

Also applies to: 17-22

app/controllers/topics_controller.rb (1)

500-500: LGTM!

Explicitly rendering :show aligns with the preloaded data stored on the previous line, ensuring the HTML response correctly uses the serialized topic view.

app/views/email/notification.html.erb (1)

5-6: Duplicate footer placement may cause confusion.

The respond_instructions footer appears twice: once inside the context_posts.present? block (line 6) and once outside it (line 19). This means when context posts are present, users will see the respond instructions twice in the email. Verify this is the intended behavior.

Also applies to: 17-20

config/routes.rb (1)

438-442: LGTM!

The new routes follow existing conventions with appropriate constraints and provide both slugged and non-slugged variants for the unsubscribe endpoint, consistent with other topic routes in this file.

Comment on lines +5 to +7
stopNotificiationsText: function() {
return I18n.t("topic.unsubscribe.stop_notifications", { title: this.get("model.fancyTitle") });
}.property("model.fancyTitle"),

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

🧩 Analysis chain

🏁 Script executed:

# Find and display the controller file content
fd -t f "topic-unsubscribe.js.es6" --exec cat -n {}

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 1261


🏁 Script executed:

# Find the template file and check its content
fd -t f "unsubscribe.hbs" --exec cat -n {}

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 332


🏁 Script executed:

# Search for references to "stopNotif" in the codebase to see all usages
rg "stopNotif" -n

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 295


Fix misspelled method name in controller and template: stopNotificiationsText should be stopNotificationsText.

The method name is missing a 'c' in "Notifications". Both the controller method and the template reference must be updated together to fix this consistently.

Proposed changes

Controller (app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6):

-  stopNotificiationsText: function() {
+  stopNotificationsText: function() {
     return I18n.t("topic.unsubscribe.stop_notifications", { title: this.get("model.fancyTitle") });
   }.property("model.fancyTitle"),

Template (app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs):

-    {{{stopNotificiationsText}}}
+    {{{stopNotificationsText}}}
📝 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
stopNotificiationsText: function() {
return I18n.t("topic.unsubscribe.stop_notifications", { title: this.get("model.fancyTitle") });
}.property("model.fancyTitle"),
stopNotificationsText: function() {
return I18n.t("topic.unsubscribe.stop_notifications", { title: this.get("model.fancyTitle") });
}.property("model.fancyTitle"),
🤖 Prompt for AI Agents
In @app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6 around
lines 5 - 7, Rename the misspelled method stopNotificiationsText to
stopNotificationsText in the controller (update the function name and its
property dependency string from "model.fancyTitle" as needed) and update the
template reference(s) in unsubscribe.hbs to use stopNotificationsText so both
sides match; search for any other usages of stopNotificiationsText and replace
them with stopNotificationsText to keep calls and bindings consistent.

Comment on lines +4 to +10
model(params) {
const topic = this.store.createRecord("topic", { id: params.id });
return PostStream.loadTopicView(params.id).then(json => {
topic.updateFromJson(json);
return topic;
});
},

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

🧩 Analysis chain

🏁 Script executed:

# Find and read the file
cat -n app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 817


🏁 Script executed:

# Search for other routes using loadTopicView to see error handling patterns
rg "loadTopicView" app/assets/javascripts/discourse/routes/ -A 5 -B 2

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 823


🏁 Script executed:

# Look at the PostStream model to understand loadTopicView
fd "post-stream" app/assets/javascripts/discourse/models/ --type f

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 143


🏁 Script executed:

# Find all route files and check error handling patterns
fd "\.es6$" app/assets/javascripts/discourse/routes/ --type f | head -10

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 717


🏁 Script executed:

# Look at topic route specifically as it's likely related
cat -n app/assets/javascripts/discourse/routes/topic.js.es6 2>/dev/null || echo "File not found"

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 8952


🏁 Script executed:

# Search for .catch patterns in routes to see error handling conventions
rg "\.catch|error:" app/assets/javascripts/discourse/routes/ -l

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 201


🏁 Script executed:

# Check the PostStream.loadTopicView implementation
head -100 app/assets/javascripts/discourse/models/post-stream.js.es6

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 3272


🏁 Script executed:

# Check topic-from-params.js.es6 for error handling patterns
cat -n app/assets/javascripts/discourse/routes/topic-from-params.js.es6

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 2634


🏁 Script executed:

# Check user.js.es6 for error handling patterns
cat -n app/assets/javascripts/discourse/routes/user.js.es6 | head -150

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 3230


🏁 Script executed:

# Search for any routes that use PostStream.loadTopicView and check their error handling
rg "PostStream\.loadTopicView" app/assets/javascripts/discourse/ -B 2 -A 10

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 2465


Add error handling for loadTopicView promise rejection.

The promise returned by PostStream.loadTopicView is not handled for rejection. If the topic doesn't exist, user lacks permission, or a network error occurs, the rejected promise will propagate without proper error handling, leaving the user without feedback.

Other Discourse routes consistently handle such async operations with .catch() blocks (see user.js.es6 and topic-from-params.js.es6). Add a .catch() handler to redirect to an error page:

Proposed fix
   model(params) {
     const topic = this.store.createRecord("topic", { id: params.id });
     return PostStream.loadTopicView(params.id).then(json => {
       topic.updateFromJson(json);
       return topic;
+    }).catch(() => {
+      this.transitionTo("exception-unknown");
     });
   },
🤖 Prompt for AI Agents
In @app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 around
lines 4 - 10, The model hook calls PostStream.loadTopicView without handling
rejections; add a .catch handler on the promise returned by
PostStream.loadTopicView(params.id) to handle errors, call topic.updateFromJson
in the .then as before, and in the .catch redirect the user to an appropriate
error route (e.g., this.transitionTo('not-found') or another centralized error
page) so failures (missing topic, permissions, network) are handled gracefully.

@@ -0,0 +1,8 @@
<div class="container">
<p>
{{{stopNotificiationsText}}}

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

Typo in property name and potential XSS concern with triple-brace rendering.

  1. Typo: stopNotificiationsText should be stopNotificationsText (extra 'i' in "Notificiations").

  2. Security: Triple braces {{{...}}} render unescaped HTML. If stopNotificationsText includes user-generated content like fancyTitle, this could introduce an XSS vulnerability. Ensure the title is properly escaped before interpolation, or use double braces if HTML rendering is not required.

🔒 Recommended fix

If HTML formatting is needed, ensure fancyTitle is sanitized in the controller. If not, use double braces:

-    {{{stopNotificiationsText}}}
+    {{stopNotificationsText}}
📝 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
{{{stopNotificiationsText}}}
{{stopNotificationsText}}
🤖 Prompt for AI Agents
In @app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs at line 3,
The template uses a misspelled property name `stopNotificiationsText` — rename
it to `stopNotificationsText` in the template (and update any corresponding
references) and remove the triple-brace rendering to prevent XSS: replace the
`{{{...}}}` usage with safe rendering by using double braces
`{{stopNotificationsText}}` unless you explicitly need HTML; if HTML is
required, ensure the controller sanitizes `fancyTitle` (or other user-provided
parts) before composing `stopNotificationsText` so only trusted HTML is output.

Comment on lines +1 to +3
export default Discourse.View.extend({
classNames: ["topic-unsubscribe"]
});

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

🧩 Analysis chain

🏁 Script executed:

# Check prevalence of Discourse.View.extend in the codebase
rg -n "Discourse\.View\.extend" --type js | wc -l

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 86


🏁 Script executed:

# Get a sample of Discourse.View usage with context
rg -n "Discourse\.View\.extend" --type js -A 1 | head -50

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 520


🏁 Script executed:

# Check if there are any Ember components or other view patterns used
rg -n "Ember\.Component|\.extend\({" --type js | head -30

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 204


🏁 Script executed:

# Search for Ember component patterns
rg -n "Ember\.Component\.extend" --type js | head -20

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 836


🏁 Script executed:

# Check the structure of the views directory
find app/assets/javascripts -type d -name "views" 2>/dev/null

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 158


🏁 Script executed:

# List files in the discourse views directory
ls -la app/assets/javascripts/discourse/views/ 2>/dev/null || echo "Directory may not exist yet"

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 5967


🏁 Script executed:

# Check how views are typically structured - look at one of the existing admin views
cat app/assets/javascripts/admin/views/admin_user_view.js

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 291


🏁 Script executed:

# Check what pattern other small discourse views use
head -10 app/assets/javascripts/discourse/views/badges-index.js.es6
head -10 app/assets/javascripts/discourse/views/badges-show.js.es6
head -10 app/assets/javascripts/discourse/views/unknown.js.es6
head -10 app/assets/javascripts/discourse/views/button.js.es6

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 1173


🏁 Script executed:

# Check the file size and pattern of topic-unsubscribe.js.es6 
cat app/assets/javascripts/discourse/views/topic-unsubscribe.js.es6

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 163


Reconsider if a View class is necessary for only setting classNames.

This view extends Discourse.View, which is consistently used throughout the codebase (both in admin and main views), but typically paired with mixins like ScrollTop or LoadMore. Since this view only sets classNames with no additional logic or mixins, it may be unnecessary to extend the View class—the CSS class could be applied at the template level instead.

🤖 Prompt for AI Agents
In @app/assets/javascripts/discourse/views/topic-unsubscribe.js.es6 around lines
1 - 3, This file exports a Discourse.View subclass that only sets classNames
("topic-unsubscribe"); remove the unnecessary view by deleting or not exporting
the Discourse.View.extend in topic-unsubscribe.js.es6 and instead apply the
"topic-unsubscribe" CSS class directly in the relevant template (or component)
where the view was used; if other modules import this symbol, replace those
imports/uses to reference the template/component with the class applied (or keep
a very small passthrough module that documents future expansion) so no empty
View subclass remains.

Comment on lines +98 to +116
def unsubscribe
@topic_view = TopicView.new(params[:topic_id], current_user)

if slugs_do_not_match || (!request.format.json? && params[:slug].blank?)
return redirect_to @topic_view.topic.unsubscribe_url, status: 301
end

tu = TopicUser.find_by(user_id: current_user.id, topic_id: params[:topic_id])

if tu.notification_level > TopicUser.notification_levels[:regular]
tu.notification_level = TopicUser.notification_levels[:regular]
else
tu.notification_level = TopicUser.notification_levels[:muted]
end

tu.save!

perform_show_response
end

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

Potential NoMethodError if TopicUser record does not exist.

TopicUser.find_by returns nil when no record exists for the user/topic pair. This occurs when a user receives an email notification for a topic they haven't explicitly interacted with. Calling tu.notification_level on nil will raise a NoMethodError.

Consider using find_or_initialize_by or add a nil-check with appropriate fallback behavior (e.g., create a new record or redirect with an error).

Proposed fix using find_or_initialize_by
-  tu = TopicUser.find_by(user_id: current_user.id, topic_id: params[:topic_id])
+  tu = TopicUser.find_or_initialize_by(user_id: current_user.id, topic_id: params[:topic_id])
+  
+  # For new records, default to regular notification level
+  tu.notification_level ||= TopicUser.notification_levels[:regular]

   if tu.notification_level > TopicUser.notification_levels[:regular]
     tu.notification_level = TopicUser.notification_levels[:regular]
   else
     tu.notification_level = TopicUser.notification_levels[:muted]
   end

   tu.save!
🧰 Tools
🪛 RuboCop (1.82.1)

[convention] 98-116: Assignment Branch Condition size for unsubscribe is too high. [<4, 31, 6> 31.83/23]

(Metrics/AbcSize)

🤖 Prompt for AI Agents
In @app/controllers/topics_controller.rb around lines 98 - 116, The unsubscribe
action currently calls TopicUser.find_by which can return nil and cause a
NoMethodError when accessing tu.notification_level; change this to use
TopicUser.find_or_initialize_by(user_id: current_user.id, topic_id:
params[:topic_id]) (or explicitly nil-check tu and initialize a new TopicUser)
so tu is always an object before reading/writing notification_level, set the
appropriate default attributes on the initialized record (e.g., user_id,
topic_id) and then save! as currently done before calling perform_show_response.

context: context,
username: username,
add_unsubscribe_link: true,
unsubscribe_url: post.topic.unsubscribe_url,

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

Potential NoMethodError if post.topic is nil.

If the topic has been deleted between the notification being created and the email being sent, post.topic will be nil, causing post.topic.unsubscribe_url to raise a NoMethodError.

Consider adding nil-safe navigation or a fallback:

Proposed fix
-      unsubscribe_url: post.topic.unsubscribe_url,
+      unsubscribe_url: post.topic&.unsubscribe_url,
📝 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
unsubscribe_url: post.topic.unsubscribe_url,
unsubscribe_url: post.topic&.unsubscribe_url,
🤖 Prompt for AI Agents
In @app/mailers/user_notifications.rb at line 295, post.topic.unsubscribe_url
can raise NoMethodError if post.topic is nil; update the code that builds the
mail payload in UserNotifications (user_notifications.rb) to use nil-safe
navigation and a sensible fallback: get the topic into a local variable (e.g.,
topic = post.topic), set unsubscribe_url using safe navigation
(topic&.unsubscribe_url) and if nil supply a fallback URL or nil-safe value (for
example a generic unsubscribe path or a URL built from post.topic_id), and
ensure any helpers used (unsubscribe_url generator) handle a nil topic
gracefully.


<% context_posts.each do |p| %>
<%= render :partial => 'email/post', :locals => {:post => p} %>
<h4 class='.previous-discussion'><%= t "user_notifications.previous_discussion" %></h4>

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

Typo in class attribute: remove leading dot.

Class names in HTML attributes should not include the leading dot (.). The dot notation is only used in CSS selectors. This will cause the CSS styles targeting .previous-discussion to not apply.

Proposed fix
-    <h4 class='.previous-discussion'><%= t "user_notifications.previous_discussion" %></h4>
+    <h4 class='previous-discussion'><%= t "user_notifications.previous_discussion" %></h4>
📝 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
<h4 class='.previous-discussion'><%= t "user_notifications.previous_discussion" %></h4>
<h4 class='previous-discussion'><%= t "user_notifications.previous_discussion" %></h4>
🤖 Prompt for AI Agents
In @app/views/email/notification.html.erb at line 10, The h4 element currently
uses an invalid class attribute value '.previous-discussion' which includes a
leading dot; update the class on the h4 (the element rendering t
"user_notifications.previous_discussion") to remove the dot so it reads
class="previous-discussion" so CSS selectors targeting .previous-discussion will
apply correctly.

Comment on lines 983 to 987
topic:
unsubscribe:
stop_notifications: "You will stop receiving notifications for <strong>{{title}}</strong>."
change_notification_state: "You can change your notification state"
filter_to: "{{post_count}} posts in topic"

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
# Search for usage of these translation keys
rg -n "topic\.unsubscribe\.stop_notifications|topic\.unsubscribe\.change_notification_state" --type js --type ts --type handlebars -B 2 -A 2

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 226


🏁 Script executed:

#!/bin/bash
# Search for "stop_notifications" in the entire codebase to find rendering context
rg -i "stop_notifications" --type js --type ts --type html --type handlebars -B 3 -A 3

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 119


🏁 Script executed:

#!/bin/bash
# Check for i18n/translation rendering patterns that might indicate html-safe behavior
rg -n "html-safe|htmlSafe|\.html\(|triple.*mustache|{{{" app/assets -A 2 -B 2 | head -50

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 253


🏁 Script executed:

#!/bin/bash
# Look for the topic unsubscribe notification logic
fd -e js -e ts -e hbs | xargs grep -l "unsubscribe" | head -10

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 232


🏁 Script executed:

cat -n app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 332


🏁 Script executed:

rg -n "stop_notifications" app/assets/javascripts -A 5 -B 5

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 1013


🏁 Script executed:

# Search for i18n helper usage and escaping behavior
rg -n "i18n|t\(" app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs -A 2 -B 2

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 223


🏁 Script executed:

# Check for any js that handles the unsubscribe view/controller
fd -e js -e ts app/assets/javascripts | xargs grep -l "unsubscribe" 2>/dev/null | head -5

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 478


🏁 Script executed:

# Look for i18n/translation rendering patterns - check how handlebars renders translations
rg -n "interpolate|htmlSafe|\.html" app/assets/javascripts/discourse -A 1 -B 1 | grep -i "i18n\|t\(" | head -20

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 108


🏁 Script executed:

# Find the I18n library implementation
fd -e js "i18n" app/ vendor/ public/ | grep -i i18n | head -10

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 165


🏁 Script executed:

# Search for I18n.t implementation to see if it escapes interpolated values
rg -n "I18n\s*=|\.t\s*=|function t\(" -A 10 vendor/assets/javascripts/ --type js | head -50

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 67


🏁 Script executed:

# Look for discourse's i18n helper or how interpolation works
rg -n "interpolate" app/assets/javascripts -A 5 | head -30

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 1369


🏁 Script executed:

# Check if there's any escaping of interpolated values in i18n
rg -n "escape|htmlEscape" vendor/assets/javascripts/ --type js -B 2 -A 2 | head -30

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 2416


🏁 Script executed:

cat -n app/assets/javascripts/locales/i18n.js | sed -n '167,200p'

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 1297


🏁 Script executed:

# Get more of the interpolate function
sed -n '167,190p' app/assets/javascripts/locales/i18n.js

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 688


🏁 Script executed:

# Look at the full interpolate function
awk '/I18n\.interpolate = function/,/^I18n\./ {print NR": "$0}' app/assets/javascripts/locales/i18n.js | head -40

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 137


🏁 Script executed:

# Check if there are any utility functions for escaping in Discourse
rg -n "Handlebars\.Utils\.escapeExpression|escape|htmlEscape" app/assets/javascripts -A 2 -B 1 | head -30

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 2621


🏁 Script executed:

# Verify what fancyTitle contains and where it comes from
rg -n "fancyTitle" app/assets/javascripts -B 2 -A 2 | head -20

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 1781


HTML injection vulnerability: {{title}} must be escaped before rendering as HTML.

The translation string contains <strong>{{title}}</strong> and is rendered via {{{stopNotificiationsText}}} (triple braces = HTML-safe). The controller passes model.fancyTitle (user-controlled topic title) to I18n.t(), but I18n.interpolate() performs direct string replacement without HTML escaping (line 188 in i18n.js). This allows attackers to inject HTML/JavaScript via the topic title.

Escaping must occur before interpolation or the rendered output will be unsafe.

🤖 Prompt for AI Agents
In @config/locales/client.en.yml around lines 983 - 987, The translation uses
raw HTML with <strong>{{title}}</strong> and is rendered with triple-brace
stopNotificiationsText, while I18n.interpolate() (used by I18n.t()) does not
escape values; fix by ensuring model.fancyTitle is HTML-escaped before
interpolation (or update I18n.interpolate() to escape interpolation values by
default) so unsafe input cannot inject HTML/JS; update the controller that
supplies model.fancyTitle (or the I18n.t() call that produces
stopNotificiationsText) to pass an escaped title, or change the interpolation
implementation to escape values prior to replacing {{title}}.

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.

2 participants