Skip to content

FEATURE: per-topic unsubscribe option in emails#9

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

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

Conversation

@akshayutture-augment

@akshayutture-augment akshayutture-augment commented Nov 14, 2025

Copy link
Copy Markdown

Test 2

Summary by CodeRabbit

  • New Features
    • Added topic unsubscribe functionality allowing users to stop receiving notifications for specific topics via a new unsubscribe page.
    • Included unsubscribe link in notification emails for easy access to notification preferences.
    • Added ability to change notification state for individual topics directly from the unsubscribe interface.

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


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

@akshayutture-augment

Copy link
Copy Markdown
Author

@coderabbitai review

@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: 4

🧹 Nitpick comments (2)
app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6 (2)

5-7: Consider modern computed property syntax.

The .property() syntax is from older Ember versions. Modern Ember uses Ember.computed() or decorators for better readability and maintainability.

Example using Ember.computed():

stopNotificationsText: Ember.computed("model.fancyTitle", function() {
  return I18n.t("topic.unsubscribe.stop_notifications", { title: this.get("model.fancyTitle") });
})

1-1: Replace ObjectController with modern Ember Controller pattern.

Ember.ObjectController was deprecated in Ember 1.11 and removed in Ember 2.0. The codebase uses Ember 1.11.3.1, where this pattern still works but generates deprecation warnings. However, 19+ files in the codebase import from discourse/controllers/object, making this a systematic modernization task.

For topic-unsubscribe.js.es6, replace the ObjectController import and migrate the computed property to modern Ember syntax:

  • Remove import ObjectController from "discourse/controllers/object"
  • Use regular Controller and access the model explicitly via model.propertyName
  • Convert .property() computed properties to @computed or native getters
📜 Review details

Configuration used: CodeRabbit UI

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 (1 hunks)
  • app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6 (1 hunks)
  • app/assets/javascripts/discourse/routes/app-route-map.js.es6 (1 hunks)
  • app/assets/javascripts/discourse/routes/topic-from-params.js.es6 (2 hunks)
  • app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 (1 hunks)
  • app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs (1 hunks)
  • app/assets/javascripts/discourse/views/topic-unsubscribe.js.es6 (1 hunks)
  • app/assets/stylesheets/common/base/topic.scss (1 hunks)
  • app/controllers/topics_controller.rb (3 hunks)
  • app/mailers/user_notifications.rb (2 hunks)
  • app/models/topic.rb (1 hunks)
  • app/models/topic_user.rb (6 hunks)
  • app/views/email/notification.html.erb (1 hunks)
  • config/locales/client.en.yml (1 hunks)
  • config/locales/server.en.yml (1 hunks)
  • config/routes.rb (1 hunks)
  • lib/email/message_builder.rb (2 hunks)
  • spec/components/email/message_builder_spec.rb (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
app/controllers/topics_controller.rb (2)
app/models/topic.rb (1)
  • unsubscribe_url (719-721)
app/models/topic_user.rb (1)
  • notification_levels (19-21)
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)
lib/email/message_builder.rb (3)
lib/validators/post_validator.rb (1)
  • presence (19-29)
app/models/topic.rb (1)
  • title (691-695)
lib/discourse.rb (1)
  • base_url (211-213)
app/mailers/user_notifications.rb (2)
app/models/notification.rb (1)
  • post (96-100)
app/models/topic.rb (1)
  • unsubscribe_url (719-721)
🪛 RuboCop (1.81.7)
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 (27)
app/mailers/user_notifications.rb (2)

310-310: LGTM! Clean refactor to single-line conditional.

The refactoring of the from_alias assignment from a multi-line if/end block to a single-line guard clause improves readability without changing behavior.


295-295: Verify nil safety for post.topic on lines 283, 295, and 300.

The code accesses post.topic without nil checks on multiple lines:

  • Line 283: template << "_pm" if post.topic.private_message?
  • Line 295: unsubscribe_url: post.topic.unsubscribe_url,
  • Line 300: private_reply: post.topic.private_message?,

While posts typically have topics, the codebase demonstrates nil-guarding for post.topic elsewhere (e.g., app/models/topic_link.rb:169, app/models/post_action.rb:200). Verify that post.topic is guaranteed to be present at this point in the notification flow, or add defensive checks (e.g., post.topic&.unsubscribe_url).

app/models/topic.rb (1)

719-721: LGTM! Clean implementation following existing patterns.

The unsubscribe_url method follows the same pattern as other URL methods in the Topic model (e.g., last_post_url, relative_url). The implementation correctly appends "/unsubscribe" to the topic's URL.

app/assets/javascripts/discourse/components/dropdown-button.js.es6 (1)

27-30: LGTM! Good defensive programming to prevent empty title elements.

The conditional rendering of the title prevents outputting an empty or undefined <h4 class='title'> element when no title is provided. This is a clean improvement that keeps the HTML output tidy.

lib/email/message_builder.rb (3)

24-28: LGTM! Formatting improvement with trailing comma.

The reformatting of the template_args hash initialization improves readability and follows Ruby best practices by adding a trailing comma, which makes future diffs cleaner when adding new keys.


34-38: LGTM! Clean refactor to ternary operator.

The refactoring from a multi-line if/else block to a ternary operator improves conciseness while maintaining the same behavior. The conditional logic is clear and readable.


59-69: Static analysis warning is a false positive.

The RuboCop warning about assignment in condition at line 62 can be safely ignored. The pattern unless html_override = @opts[:html_override] is idiomatic Ruby that combines assignment with a guard clause. This is intentional and widely used in Ruby codebases. The code correctly returns nil when @opts[:html_override] is falsy, which is the expected behavior.

spec/components/email/message_builder_spec.rb (1)

169-172: LGTM! Test properly updated for new unsubscribe_url parameter.

The test correctly adds the unsubscribe_url parameter to align with the updated Email::MessageBuilder initialization. The URL format matches the expected pattern from the Topic model's unsubscribe_url method.

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

1852-1855: LGTM! Clear messaging for both global and topic-specific unsubscribe options.

The locale update correctly adds topic-specific unsubscribe functionality while preserving the existing global unsubscribe option. The YAML block scalar format is appropriate for multi-line content, and the message clearly distinguishes between the two unsubscribe options available to users.

app/models/topic_user.rb (1)

8-13: LGTM! Formatting improvements throughout the file.

The changes consist entirely of formatting and style improvements:

  • Improved alignment in the tracking scope's where clause
  • Simplified the create_lookup iteration to a single-line block
  • Consistent formatting of MessageBus.publish calls
  • Better readability in update_all statements

No behavioral changes detected. These improvements enhance code consistency and maintainability.

Also applies to: 64-64, 113-113, 125-125, 194-194, 218-218

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

3-3: LGTM! Updated to modern Ruby syntax.

The partial render call has been updated from hash-rocket style to modern Ruby 1.9+ syntax (post: post), which is the current convention.


5-15: LGTM! Improved content organization in context posts block.

The reorganization improves the email layout by:

  • Placing respond instructions in a footer before the context section
  • Adding clear visual separation with <hr> and a heading
  • Updating partial render syntax to modern Ruby style

The structure provides better visual hierarchy in the email.


17-20: LGTM! Clear footer structure with both instruction types.

The consolidated footer section properly separates:

  • Respond instructions (how to reply)
  • Unsubscribe link (how to opt out)

Both are wrapped in separate footer divs, which allows for consistent styling and clear visual separation from the main content.


24-28: LGTM! Improved indentation in schema.org metadata block.

The indentation changes improve readability of the schema.org EmailMessage metadata block without altering its functionality.

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

5-16: LGTM: ES6 modernization improves code quality.

The conversion to ES6 method shorthand and const declarations improves readability and scoping. These changes align with modern JavaScript best practices.


58-60: Good addition: Error handling for promise rejection.

The .catch() handler provides graceful error handling for topic loading failures, improving robustness.

app/assets/javascripts/discourse/routes/app-route-map.js.es6 (1)

13-13: LGTM: Route declaration follows established conventions.

The new topicUnsubscribe route is properly declared with appropriate path parameters, consistent with existing topic routes.

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

1-3: LGTM: Simple view implementation.

The view correctly extends Discourse.View and applies the appropriate CSS class for styling.

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

67-77: LGTM: Styling supports inline notification controls.

The CSS properly positions the notification dropdown within the unsubscribe page by removing floats and adjusting layout properties.

config/routes.rb (1)

438-442: LGTM: Routes properly constrained and organized.

The unsubscribe routes are correctly added with appropriate constraints (topic_id: /\d+/) and support both slug-based and ID-only paths, consistent with other topic routes.

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

12-15: LGTM: Appropriately clears notification reason.

Clearing the notificationReasonText on the unsubscribe page makes sense, as the user is explicitly managing their notification preferences.


17-22: LGTM: Footer visibility handled correctly.

Setting showFooter to true ensures the application footer displays on the unsubscribe page, providing consistent navigation.


4-10: The suggested error handling approach has a critical issue: the "exception-unknown" route doesn't exist.

The review comment proposes adding .catch(error => this.transitionTo("exception-unknown")), but only exception.js.es6 exists in the codebase. More importantly, the application route already defines a global error(err, transition) action that handles promise rejections from the model hook:

  • 404 errors transition to 'unknown'
  • Other errors transition to 'exception'

This global handler catches rejected promises from route models automatically. The lack of explicit error handling in topic-unsubscribe.js.es6 follows the pattern used throughout the codebase—most routes rely on this centralized error handling rather than adding .catch() in individual model hooks.

If you want to add route-specific error handling, use the pattern from preferences-about.js.es6 (which uses .then(success, failure) with a user alert), but the current design already prevents blank error screens.

Likely an incorrect or invalid review comment.

app/controllers/topics_controller.rb (4)

27-28: LGTM!

The addition of :unsubscribe to the ensure_logged_in filter correctly ensures users must be authenticated to access the unsubscribe action.


32-32: LGTM!

Skipping the XHR check for the unsubscribe action is appropriate, allowing users to access the unsubscribe page directly via URL (similar to show and feed).


107-111: Verify toggle behavior for unsubscribe action.

The current logic toggles between notification levels rather than simply unsubscribing:

  • If tracking or watching → downgrade to regular
  • If regular or muted → mute

This differs from typical "unsubscribe" behavior, which would unconditionally mute notifications. Please confirm this toggle behavior is intentional for the unsubscribe feature.


500-500: LGTM!

The explicit render :show improves code clarity by making the response behavior explicit for HTML format requests.

@akshayutture-augment

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 21, 2025

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 Nov 21, 2025

Copy link
Copy Markdown

Walkthrough

This change introduces a topic unsubscribe feature across the frontend and backend. It adds a new route, controller, and view for unsubscribe pages; updates the backend controller to handle unsubscribe requests and manage notification levels; adds unsubscribe URLs to email notifications; includes localization strings; and modernizes some frontend code to ES6 syntax.

Changes

Cohort / File(s) Summary
Frontend Unsubscribe Feature
app/assets/javascripts/discourse/controllers/topic-unsubscribe.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
New controller with computed stopNotificiationsText property, route with model(), afterModel(), and actions.didTransition() handlers for loading and displaying topic unsubscribe page, template rendering notification state message with action button, and view with unsubscribe class styling.
Route Definitions
app/assets/javascripts/discourse/routes/app-route-map.js.es6, config/routes.rb
Added topicUnsubscribe route at /t/:slug/:id/unsubscribe in Ember routing, re-added moderator_liked route, and added server-side unsubscribe routes for topics with slug-based and numeric variants.
Backend Topic Handling
app/controllers/topics_controller.rb, app/models/topic.rb, app/models/topic_user.rb
New unsubscribe action in TopicsController that initializes TopicView, adjusts notification_level (setting to regular if above, muting otherwise), and renders show response; new unsubscribe_url instance method on Topic model; formatting adjustments in TopicUser.
Email Notifications
app/mailers/user_notifications.rb, app/views/email/notification.html.erb, lib/email/message_builder.rb, spec/components/email/message_builder_spec.rb
Added unsubscribe_url to email options in mailer, updated HTML email template to include unsubscribe link in footer with previous discussion context reorganization, refactored message builder to pass unsubscribe URLs through template arguments, and updated spec to verify unsubscribe_url parameter handling.
Frontend Modernization
app/assets/javascripts/discourse/routes/topic-from-params.js.es6
Converted function-style route handlers to ES6 method shorthand (model(), setupController()), replaced var declarations with const, introduced local constants for controller references, and added enteredAt property.
Styling & Components
app/assets/stylesheets/common/base/topic.scss, app/assets/javascripts/discourse/components/dropdown-button.js.es6
Added CSS block for .topic-unsubscribe with adjusted dropdown layout (inline-block display, no float, reset bottom); modified dropdown component to conditionally render title element.
Localization
config/locales/client.en.yml, config/locales/server.en.yml
Added new i18n keys under en.topic.unsubscribe for stop_notifications and change_notification_state; removed topic.likes translation block; expanded server-side unsubscribe message to multi-line format with unsubscribe URL link.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Browser
    participant Router as Ember Router
    participant Controller as Topic-Unsubscribe Controller
    participant Route as Topic-Unsubscribe Route
    participant API as Backend API
    participant TopicsCtrl as Topics Controller
    participant Model as Topic Model

    User->>Browser: Navigate to /t/topic-slug/123/unsubscribe
    Browser->>Router: Route transition
    Router->>Route: Enter topicUnsubscribe route
    Route->>API: GET /t/topic-slug/123 (load topic view)
    API->>TopicsCtrl: unsubscribe action
    TopicsCtrl->>Model: Get/create topic with id
    TopicsCtrl->>Model: Update from fetched JSON
    TopicsCtrl->>API: Update TopicUser notification_level
    API-->>TopicsCtrl: Success
    TopicsCtrl-->>API: Render show response
    API-->>Route: Topic JSON + HTML response
    Route->>Model: Create topic record
    Route->>Controller: setupController with model
    Controller->>Controller: Compute stopNotificiationsText
    Controller-->>Browser: Render unsubscribe template
    Browser->>User: Display unsubscribe confirmation page
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20–30 minutes

Areas requiring extra attention:

  • app/controllers/topics_controller.rb — New unsubscribe action modifying TopicUser notification state; verify redirect logic, slug validation, and state-change logic (regular vs. mute condition)
  • app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 — Route model/afterModel lifecycle; verify PostStream integration and topic JSON synchronization
  • app/mailers/user_notifications.rb and lib/email/message_builder.rb — Unsubscribe URL handling in email assembly; verify template variable substitution and link generation
  • config/routes.rb — Route ordering and slug parameter handling for unsubscribe endpoints

Poem

🐰 A button to flee from the flood,
of notifications that stick like mud—
Now topics release those who say "no more,"
with unsubscribe links in the mailer's lore.
One click, and peace returns once more! 🎉

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly and specifically describes the main feature being added: a per-topic unsubscribe option in emails, which aligns with the comprehensive changes across controllers, routes, views, and email templates.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch topic-email-management

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: 6

Caution

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

⚠️ Outside diff range comments (2)
app/assets/javascripts/discourse/routes/topic-from-params.js.es6 (1)

32-35: Remove the unused enteredAt property or document its intended use.

Verification confirms that enteredAt: new Date().getTime().toString() is set on topicController in app/assets/javascripts/discourse/lib/url.js:230 but is never read or consumed anywhere in the codebase. This unused property should either be removed or accompanied by documentation and tests explaining its purpose.

app/models/topic_user.rb (1)

120-131: Bug in track_visit! uses topic instead of user when deriving user_id

user_id = user.is_a?(User) ? user.id : topic will assign the topic identifier as user_id whenever user is not a User instance (e.g., when an ID is passed), corrupting the (topic_id, user_id) pair:

def track_visit!(topic,user)
  topic_id = topic.is_a?(Topic) ? topic.id : topic
  user_id = user.is_a?(User) ? user.id : topic  # <- should use `user`

This predates the formatting change but is a correctness issue worth fixing now.

Suggested fix:

 def track_visit!(topic,user)
   topic_id = topic.is_a?(Topic) ? topic.id : topic
-  user_id = user.is_a?(User) ? user.id : topic
+  user_id = user.is_a?(User) ? user.id : user
🧹 Nitpick comments (5)
app/assets/javascripts/discourse/routes/topic-from-params.js.es6 (1)

7-16: Complete the ES6 modernization by using arrow functions.

The ES6 method shorthand and const declarations are good improvements. However, the const self = this binding on line 11 is unnecessary if you use arrow functions for the callbacks on lines 23 and 58.

Apply this diff to eliminate the self binding and complete the ES6 modernization:

-    const self = this,
-          topic = this.modelFor('topic'),
+    const topic = this.modelFor('topic'),
           postStream = topic.get('postStream'),
           topicController = this.controllerFor('topic'),
           topicProgressController = this.controllerFor('topic-progress'),
           composerController = this.controllerFor('composer');
 
     // I sincerely hope no topic gets this many posts
     if (params.nearPost === "last") { params.nearPost = 999999999; }
 
     params.forceLoad = true;
 
-    postStream.refresh(params).then(function () {
+    postStream.refresh(params).then(() => {
       // TODO we are seeing errors where closest post is null and this is exploding
       // we need better handling and logging for this condition.
 
       // The post we requested might not exist. Let's find the closest post
       const closestPost = postStream.closestPostForPostNumber(params.nearPost || 1),
             closest = closestPost.get('post_number'),
             progress = postStream.progressIndexOfPost(closestPost);
 
       topicController.setProperties({
         'model.currentPost': closest,
         enteredAt: new Date().getTime().toString(),
       });
 
       topicProgressController.setProperties({
         progressPosition: progress,
         expanded: false
       });
 
       // Highlight our post after the next render
-      Ember.run.scheduleOnce('afterRender', function() {
-        self.appEvents.trigger('post:highlight', closest);
+      Ember.run.scheduleOnce('afterRender', () => {
+        this.appEvents.trigger('post:highlight', closest);
       });
 
       Discourse.URL.jumpToPost(closest);
 
       if (topic.present('draft')) {
         composerController.open({
           draft: Discourse.Draft.getLocal(topic.get('draft_key'), topic.get('draft')),
           draftKey: topic.get('draft_key'),
           draftSequence: topic.get('draft_sequence'),
           topic: topic,
           ignoreIfChanged: true
         });
       }
-    }).catch(function(e) {
+    }).catch((e) => {
       Ember.warn('Could not view topic', e);
     });
app/models/topic_user.rb (1)

112-114: MessageBus publish calls are consistent; consider DRYing topic channel

All three MessageBus.publish("/topic/#{topic_id}", { notification_level_change: ... }, user_ids: [...]) calls are consistent and correct. If you touch this again, factoring out the channel string or a tiny helper would reduce duplication, but it’s not urgent.

Also applies to: 193-195, 218-219

lib/email/message_builder.rb (1)

61-64: Clean up assignment in condition in html_part to satisfy RuboCop

if response_instructions = @template_args[:respond_instructions] is valid Ruby but triggers Lint/AssignmentInCondition. To keep the intent clear and silence the warning, you can split it:

-        if response_instructions = @template_args[:respond_instructions]
-          respond_instructions = PrettyText.cook(response_instructions).html_safe
-          html_override.gsub!("%{respond_instructions}", respond_instructions)
-        end
+        response_instructions = @template_args[:respond_instructions]
+        if response_instructions
+          cooked = PrettyText.cook(response_instructions).html_safe
+          html_override.gsub!("%{respond_instructions}", cooked)
+        end
app/controllers/topics_controller.rb (1)

27-28: Consider adding a trailing comma.

For consistency with Ruby style conventions, consider adding a trailing comma after :unsubscribe on line 28. This makes future additions cleaner and reduces diff noise.

Based on learnings: This aligns with RuboCop's TrailingCommaInArrayLiteral convention.

Apply this diff:

                                          :bookmark,
-                                          :unsubscribe]
+                                          :unsubscribe,]
app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6 (1)

1-3: Consider migrating from deprecated ObjectController.

ObjectController was deprecated in Ember 1.11. Consider using a standard Controller and accessing model properties directly (e.g., this.get('model.fancyTitle') instead of relying on ObjectController's proxying behavior).

If this Discourse version still actively uses ObjectController elsewhere, this refactor can be deferred. However, verify the Ember version in use to confirm compatibility.

📜 Review details

Configuration used: CodeRabbit UI

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 (1 hunks)
  • app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6 (1 hunks)
  • app/assets/javascripts/discourse/routes/app-route-map.js.es6 (1 hunks)
  • app/assets/javascripts/discourse/routes/topic-from-params.js.es6 (2 hunks)
  • app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 (1 hunks)
  • app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs (1 hunks)
  • app/assets/javascripts/discourse/views/topic-unsubscribe.js.es6 (1 hunks)
  • app/assets/stylesheets/common/base/topic.scss (1 hunks)
  • app/controllers/topics_controller.rb (3 hunks)
  • app/mailers/user_notifications.rb (2 hunks)
  • app/models/topic.rb (1 hunks)
  • app/models/topic_user.rb (6 hunks)
  • app/views/email/notification.html.erb (1 hunks)
  • config/locales/client.en.yml (1 hunks)
  • config/locales/server.en.yml (1 hunks)
  • config/routes.rb (1 hunks)
  • lib/email/message_builder.rb (2 hunks)
  • spec/components/email/message_builder_spec.rb (1 hunks)
🧰 Additional context used
🪛 RuboCop (1.81.7)
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 (18)
app/assets/javascripts/discourse/routes/topic-from-params.js.es6 (2)

5-5: LGTM: Clean ES6 method shorthand conversion.

The conversion to ES6 method shorthand is correct and improves code readability.


28-30: LGTM: Good use of const for immutable bindings.

Converting these declarations from var to const is a good practice that prevents accidental reassignment.

app/assets/javascripts/discourse/components/dropdown-button.js.es6 (1)

26-30: Guarded title rendering in renderString looks good

Using a local title and only emitting the <h4> when truthy avoids empty headings and matches the existing raw-HTML rendering style in this component. Assuming title remains trusted/escaped upstream, this is fine.

app/models/topic_user.rb (2)

8-13: tracking scope chaining is clearer and behavior-preserving

The split where calls with named params keep the SQL condition readable without changing semantics.


60-66: Lookup creation loop style change is fine

Switching to a single-line block for topic_users.each is purely stylistic; behavior is unchanged and still straightforward.

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

983-987: New js.topic.unsubscribe strings look correct

The added stop_notifications and change_notification_state keys use the expected {{title}} interpolation style and HTML markup pattern used elsewhere in this file, so they should plug into the unsubscribe UI without issues.

app/models/topic.rb (1)

719-721: unsubscribe_url helper is simple and correctly based on canonical topic URL

Using url (the Discourse.base_url-based canonical topic URL) and appending /unsubscribe gives you a stable per-topic unsubscribe endpoint ideal for emails and links. Callers should prefer this helper over hand-assembling the path.

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

1852-1855: LGTM! Unsubscribe messaging is clear and well-structured.

The multi-line unsubscribe block provides users with both general unsubscribe options and topic-specific unsubscribe functionality. The YAML syntax and interpolation variables are correctly formatted.

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

67-77: LGTM! Clean styling for the unsubscribe UI.

The layout adjustments for .topic-unsubscribe look good. The use of initial for the bottom property has broad browser support and should work fine in this context.

config/routes.rb (1)

438-442: LGTM! Routes are well-structured and consistent.

The new unsubscribe routes follow existing patterns in the codebase, providing both slug-based and numeric topic ID variants with proper constraints. The routing setup aligns well with the new topic unsubscribe feature.

app/mailers/user_notifications.rb (2)

295-295: Good addition of topic-specific unsubscribe URL.

Adding unsubscribe_url to email options enables per-topic unsubscribe functionality in notification emails. This integrates cleanly with the new Topic#unsubscribe_url method.


310-310: Cleaner conditional assignment.

The refactor from multi-line to single-line conditional is more idiomatic Ruby and improves readability.

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

1-3: LGTM! Simple and correct view definition.

The view properly extends Discourse.View and sets the class name that matches the CSS styling in topic.scss. This follows the standard Ember/Discourse pattern.

app/assets/javascripts/discourse/routes/app-route-map.js.es6 (1)

13-13: LGTM! Route properly registered.

The topicUnsubscribe route is correctly defined with a path that matches the server-side routes in config/routes.rb. Good placement alongside other topic-related routes.

spec/components/email/message_builder_spec.rb (1)

171-172: LGTM! Test properly updated for new functionality.

The test now passes unsubscribe_url parameter alongside add_unsubscribe_link: true, correctly verifying the new per-topic unsubscribe feature. The URL format matches the expected route pattern.

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

6-6: Verify intentional duplication of respond_instructions.

The %{respond_instructions} placeholder appears twice: once inside the context_posts block (line 6) and once in the footer (line 19). This means when context posts are present, the respond instructions will be displayed twice in the email. Please confirm this duplication is intentional.

Also applies to: 19-19

app/controllers/topics_controller.rb (1)

493-507: LGTM: Explicit render improves clarity.

The explicit render :show on line 500 clarifies the template being rendered for HTML responses, which is especially helpful now that perform_show_response is called from both show and unsubscribe actions.

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

12-15: LGTM: Notification reason cleared appropriately.

Clearing the notificationReasonText in afterModel is appropriate for the unsubscribe flow, as the user is explicitly managing their subscription and doesn't need to see why they were previously notified.

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 | 🔴 Critical

Fix typo in method name.

The method name stopNotificiationsText has a typo: "Notificiations" should be "Notifications". This will cause the template to fail when trying to access this property.

Apply this diff to fix the typo:

-  stopNotificiationsText: function() {
+  stopNotificationsText: function() {
     return I18n.t("topic.unsubscribe.stop_notifications", { title: this.get("model.fancyTitle") });
   }.property("model.fancyTitle"),
📝 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 to 7, the method name stopNotificiationsText contains a typo; rename it
to stopNotificationsText (fix the spelling) while preserving its body and the
.property("model.fancyTitle") dependency so templates can access
model.fancyTitle via stopNotificationsText; also search and update any template
or JS references that used the old misspelled name to the corrected
stopNotificationsText.

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 | 🟠 Major

Add error handling for topic loading.

The PostStream.loadTopicView promise has no error handling. If the topic doesn't exist or the user lacks access, the promise rejection will bubble up unhandled, potentially leaving the user on a broken page.

Apply this diff to add error handling:

   model(params) {
     const topic = this.store.createRecord("topic", { id: params.id });
-    return PostStream.loadTopicView(params.id).then(json => {
-      topic.updateFromJson(json);
-      return topic;
-    });
+    return PostStream.loadTopicView(params.id)
+      .then(json => {
+        topic.updateFromJson(json);
+        return topic;
+      })
+      .catch(() => {
+        this.transitionTo("discovery.latest");
+      });
   },
📝 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
model(params) {
const topic = this.store.createRecord("topic", { id: params.id });
return PostStream.loadTopicView(params.id).then(json => {
topic.updateFromJson(json);
return topic;
});
},
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("discovery.latest");
});
},
🤖 Prompt for AI Agents
In app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 around lines
4–10, PostStream.loadTopicView is missing error handling; wrap the promise with
a .catch handler that handles load failures by logging the error, rolling
back/deleting the created topic record (topic.deleteRecord() or
topic.rollbackAttributes()), and transitioning away (e.g.
this.transitionTo('not-found') or another appropriate route) so the user is not
left on a broken page; ensure the handler returns a value or transition to stop
the route from rendering a broken model.

@@ -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 | 🟡 Minor

🧩 Analysis chain

Typo in variable name: "stopNotificiationsText".

The variable is misspelled as stopNotificiationsText (missing 't' in "Notifications"). This should be stopNotificationsText for consistency and readability. Please verify this typo is consistently used in the controller, or correct it throughout.

Additionally, the triple braces {{{ }}} render unescaped HTML. Ensure the stopNotificationsText value is properly sanitized to prevent XSS vulnerabilities.

Apply this diff if the typo needs correction:

-    {{{stopNotificiationsText}}}
+    {{{stopNotificationsText}}}

🏁 Script executed:

#!/bin/bash
# Verify the exact content of the template file
cat app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs

Length of output: 191


🏁 Script executed:

#!/bin/bash
# Search for stopNotifications variable in controller and helper files
rg -n "stopNotif" --type=ts --type=js -C3 app/

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for this specific template being rendered or referenced
rg -n "unsubscribe" --type=ts --type=js app/ | head -20

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for stopNotifications (correct spelling) anywhere in codebase
rg -n "stopNotifications" --type-list | head -5
rg -n "stopNotifications" -C3

Length of output: 389


🏁 Script executed:

#!/bin/bash
# Search for stopNotificiations (exact typo) everywhere
rg -n "stopNotificiations" -C3

Length of output: 1303


🏁 Script executed:

#!/bin/bash
# Find controller/route files related to unsubscribe
fd -e "js" -e "ts" -e "rb" | xargs grep -l "unsubscribe" 2>/dev/null | head -20

Length of output: 557


🏁 Script executed:

#!/bin/bash
# Look for Ember controller files
fd -name "*controller*" -o -name "*route*" | head -20

Length of output: 230


Typo confirmed in variable name across multiple files: "stopNotificiationsText" should be "stopNotificationsText".

The misspelling exists consistently in both the template and controller, so the code functionally works. However, this spelling error should be corrected for maintainability:

  • app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs line 3
  • app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6 line 5

The triple braces in the template render unescaped HTML; verify the controller's computed property sanitizes the content appropriately.

🤖 Prompt for AI Agents
In app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs around line
3 and in app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6
around line 5, rename the misspelled property stopNotificiationsText to
stopNotificationsText in both the template and the controller so names match;
update all references in these files (and any other files importing/using it) to
the corrected spelling, and in the controller ensure the computed property
returns sanitized/HTML-safe content before the template's triple-brace render
(or change to double braces if HTML is not needed).

Comment on lines +105 to +113
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!

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

Handle nil TopicUser record.

The code calls TopicUser.find_by which can return nil if no record exists for the user-topic pair. The subsequent lines access tu.notification_level and call tu.save! without checking for nil, which will raise a NoMethodError if the record doesn't exist.

Apply this diff to handle the nil case:

     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!
+    if tu
+      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!
+    else
+      TopicUser.create!(
+        user_id: current_user.id,
+        topic_id: params[:topic_id],
+        notification_level: TopicUser.notification_levels[:muted]
+      )
+    end
📝 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
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!
tu = TopicUser.find_by(user_id: current_user.id, topic_id: params[:topic_id])
if tu
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!
else
TopicUser.create!(
user_id: current_user.id,
topic_id: params[:topic_id],
notification_level: TopicUser.notification_levels[:muted]
)
end
🤖 Prompt for AI Agents
In app/controllers/topics_controller.rb around lines 105 to 113, the code
assumes TopicUser.find_by always returns a record; first check if tu is nil and
handle it (e.g., render head :not_found or render json error with appropriate
status and return) before accessing tu.notification_level or calling tu.save!;
if you prefer to create a default TopicUser instead, instantiate one with the
appropriate attributes and initial notification_level, then proceed to update
and save—either way ensure the nil branch returns or creates so no NoMethodError
occurs.


<% 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 | 🔴 Critical

Remove the leading dot from the class name.

The class attribute contains .previous-discussion with a leading dot, which is invalid HTML syntax. The dot is CSS selector syntax and should not appear in the HTML class attribute.

Apply this diff to fix the class name:

-    <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 around line 10, the class attribute
value incorrectly includes a leading dot ('.previous-discussion'); remove the
leading dot so the class is "previous-discussion" to produce valid HTML, i.e.,
replace ".previous-discussion" with "previous-discussion" in that h4 tag.

Comment on lines +24 to +28
@template_args = {
site_name: SiteSetting.email_prefix.presence || SiteSetting.title,
base_url: Discourse.base_url,
user_preferences_url: "#{Discourse.base_url}/my/preferences",
}.merge!(@opts)

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

🧩 Analysis chain

Verify per-topic unsubscribe_url is actually wired through template and headers

You’re now:

  • Building @template_args with site_name, base_url, and user_preferences_url.
  • When add_unsubscribe_link is set, injecting an unsubscribe_link snippet via I18n.t('unsubscribe_link', template_args) into both HTML and text bodies.
  • Still setting the List-Unsubscribe header to <#{template_args[:user_preferences_url]}>.

Given the new Topic#unsubscribe_url and mailer changes mentioned in the PR description, double‑check:

  1. That :unsubscribe_url is included in @opts and propagated into @template_args (e.g., via the merge), and
  2. That either the unsubscribe_link I18n string or the List-Unsubscribe header (or both) actually use that per‑topic URL rather than always pointing at the global preferences page.

If the intent is truly per‑topic unsubscribe, you probably want something along the lines of:

- @template_args = {
-   site_name: ...,
-   base_url: ...,
-   user_preferences_url: "#{Discourse.base_url}/my/preferences",
- }.merge!(@opts)
+ @template_args = {
+   site_name: ...,
+   base_url: Discourse.base_url,
+   user_preferences_url: "#{Discourse.base_url}/my/preferences",
+ }.merge!(@opts)
+
+ unsubscribe_url = @template_args[:unsubscribe_url] || @template_args[:user_preferences_url]

and then in header_args:

- result['List-Unsubscribe'] = "<#{template_args[:user_preferences_url]}>" if @opts[:add_unsubscribe_link]
+ if @opts[:add_unsubscribe_link]
+   url = template_args[:unsubscribe_url] || template_args[:user_preferences_url]
+   result['List-Unsubscribe'] = "<#{url}>"
+ end

Please also ensure the unsubscribe_link translation only references interpolation keys that exist in template_args, otherwise I18n.t will raise at runtime.

Also applies to: 61-69, 104-108


🏁 Script executed:

#!/bin/bash
# Examine the full message_builder.rb file to understand the structure
cat lib/email/message_builder.rb

Length of output: 5929


🏁 Script executed:

#!/bin/bash
# Search for unsubscribe_url assignments or usage across the codebase
rg -n "unsubscribe_url" --type=rb

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Search for List-Unsubscribe header setup
rg -n "List-Unsubscribe" --type=rb

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Find I18n translations related to unsubscribe_link
fd -e yml -e yaml | xargs rg -l "unsubscribe_link" 2>/dev/null | head -20

Length of output: 597


🏁 Script executed:

#!/bin/bash
# Find the English locale file with unsubscribe_link definition
grep -n "unsubscribe_link" config/locales/server.en.yml | head -20

Length of output: 163


🏁 Script executed:

#!/bin/bash
# Search for unsubscribe_url usage (without type restriction)
rg -n "unsubscribe_url" --max-count=50

Length of output: 952


🏁 Script executed:

#!/bin/bash
# Search for add_unsubscribe_link usage in mailers and related code
rg -n "add_unsubscribe_link" --max-count=50

Length of output: 980


🏁 Script executed:

#!/bin/bash
# Look for MessageBuilder initialization with opts to see what gets passed
rg -n "MessageBuilder.new" -C 2 --max-count=30

Length of output: 5304


🏁 Script executed:

#!/bin/bash
# Get full context around user_notifications.rb line 295 to see the complete opts being passed
sed -n '290,310p' app/mailers/user_notifications.rb

Length of output: 786


🏁 Script executed:

#!/bin/bash
# Get the full unsubscribe_link translation to see exactly what it uses
sed -n '1852,1857p' config/locales/server.en.yml

Length of output: 237


🏁 Script executed:

#!/bin/bash
# Look at the test for unsubscribe to see what's expected
grep -A 10 "with add_unsubscribe_link true" spec/components/email/message_builder_spec.rb

Length of output: 552


Fix List-Unsubscribe header to use per-topic unsubscribe_url and remove redundant condition

The review comment is correct. Your code partially implements per-topic unsubscribe:

  • The unsubscribe_url from post.topic.unsubscribe_url is passed in opts at line 295
  • Lines 24-28 merge this into @template_args via .merge!(@opts)
  • The unsubscribe_link translation uses both %{user_preferences_url} and %{unsubscribe_url}, both of which are available in template_args

However, lines 106-107 have two issues:

  1. List-Unsubscribe header ignores the per-topic URL: It hardcodes user_preferences_url instead of checking for unsubscribe_url first, making it inconsistent with the email body which includes the topic-specific unsubscribe link.

  2. Redundant if statement: The outer if @opts[:add_unsubscribe_link] at line 106 makes the inner condition at line 107 redundant.

Update lines 106-108 to:

if @opts[:add_unsubscribe_link]
  url = @template_args[:unsubscribe_url] || @template_args[:user_preferences_url]
  result['List-Unsubscribe'] = "<#{url}>"
end

This ensures the RFC 8058 header uses the per-topic URL when available, matching the email body behavior.

🤖 Prompt for AI Agents
In lib/email/message_builder.rb around lines 106 to 108, the List-Unsubscribe
header currently ignores the per-topic unsubscribe_url and has a redundant
conditional; replace the block so that when @opts[:add_unsubscribe_link] is true
it sets url = @template_args[:unsubscribe_url] ||
@template_args[:user_preferences_url] and then assigns
result['List-Unsubscribe'] = "<#{url}>", thereby preferring the per-topic
unsubscribe link and removing the redundant inner check.

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