Skip to content

FEATURE: per-topic unsubscribe option in emails - #2

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

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

Conversation

@everettbu

Copy link
Copy Markdown
Contributor

Test 2

@mfeuerstein mfeuerstein 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.

PR Review — approved

Reviewed 18 files. 0 high-severity issues found. Verdict: approved.

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

  • Reviewed app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs — looks good

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

  • Reviewed app/assets/javascripts/discourse/routes/topic-from-params.js.es6 — looks good

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

  • Reviewed app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6 — looks good

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

  • Reviewed app/assets/javascripts/discourse/components/dropdown-button.js.es6 — looks good

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

  • Reviewed app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 — looks good

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

  • Reviewed app/assets/javascripts/discourse/routes/app-route-map.js.es6 — looks good

app/mailers/user_notifications.rb (low)

  • Reviewed app/mailers/user_notifications.rb — looks good

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

  • Reviewed app/assets/javascripts/discourse/views/topic-unsubscribe.js.es6 — looks good

app/models/topic_user.rb (low)

  • Reviewed app/models/topic_user.rb — looks good

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

  • Reviewed app/assets/stylesheets/common/base/topic.scss — looks good

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

  • Reviewed app/views/email/notification.html.erb — looks good

config/routes.rb (low)

  • Reviewed config/routes.rb — looks good

app/controllers/topics_controller.rb (low)

  • Reviewed app/controllers/topics_controller.rb — looks good

app/models/topic.rb (low)

  • Reviewed app/models/topic.rb — looks good

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

  • Reviewed config/locales/server.en.yml — looks good

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

  • Reviewed spec/components/email/message_builder_spec.rb — looks good

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

  • Reviewed config/locales/client.en.yml — looks good

lib/email/message_builder.rb (low)

  • Reviewed lib/email/message_builder.rb — looks good

@zach-source zach-source 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.

Two nil crashes in unsubscribe logic, CSRF vulnerability on GET routes — see inline.

@ron-x5labs ron-x5labs 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.

Code Review: Test 2

Problem

The PR description is empty ("Test 2"), so the goal is inferred from the diff: add a per-topic unsubscribe option to notification emails — an unsubscribe link in the email body that lands the user on a topic unsubscribe page which mutes/normalizes their notification level for that topic.

Solution Reviewed

  • New Topic#unsubscribe_url ("#{url}/unsubscribe") threaded into UserNotifications#send_notification_email and the unsubscribe_link i18n string.
  • New TopicsController#unsubscribe action (GET route) that toggles the user's TopicUser#notification_level (watching/tracking -> regular, otherwise -> muted), then renders the topic show page.
  • Ember client: topic-unsubscribe route/controller/view/template + unsubscribe.hbs confirmation screen, plus a guard in dropdown-button to omit the title <h4> when absent.
  • Email template/locales and a small message_builder_spec update; assorted ES6/Ruby style refactors.

Summary

The feature is wired end-to-end, but the core server endpoint has two blocking defects: a nil crash on users without a TopicUser record (a common path for mentioned/category-watching users), and a state-mutating GET route that is a CSRF vector and silently toggles state on prefetch/refresh. These should be fixed before merge.

Files Reviewed

  • app/controllers/topics_controller.rb — deeply reviewed (unsubscribe action, perform_show_response render change)
  • config/routes.rb — deeply reviewed (route ordering, new unsubscribe routes)
  • app/models/topic.rb, app/models/topic_user.rb — reviewed (unsubscribe_url; stylistic refactor + track_visit! scope)
  • app/mailers/user_notifications.rb, lib/email/message_builder.rb — deeply reviewed (unsubscribe_url plumbing, unsubscribe_link interpolation contract)
  • app/views/email/notification.html.erb — reviewed (template restructure, placeholder substitution)
  • config/locales/client.en.yml, config/locales/server.en.yml — reviewed (new keys, unsubscribe_link block scalar)
  • Ember JS (route/controller/view/template + app-route-map, topic-from-params, dropdown-button) — reviewed
  • app/assets/stylesheets/common/base/topic.scss — lightly reviewed (unsubscribe styles)
  • spec/components/email/message_builder_spec.rb — reviewed (assertion coverage gap)

Verification

  • bundle exec rspec / Rails test suite — skipped: no Ruby toolchain available in this environment (ruby/bundle not installed). Static review only.

Issues Found

See inline comments. In addition, one non-blocking suggestion that has no single diff line:

  • lib/email/message_builder.rb (header_args) — the List-Unsubscribe header still points at user_preferences_url (generic preferences page) while the email body now advertises a one-click per-topic unsubscribe URL. For RFC 8058 one-click-unsubscribe compliance and consistency, consider pointing the header at template_args[:unsubscribe_url] when present.

Verdict

Recommend changes before merge — fix the nil deref and the state-mutating GET before this ships.

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])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking — nil deref crashes the unsubscribe link for users without a TopicUser record. TopicUser.find_by returns nil when the user has no per-topic record (e.g. they were only @mentioned, are watching via a group/category, or hit the URL directly). The very next line (tu.notification_level) then raises NoMethodError -> 500 on the unsubscribe page — the exact action the email link triggers. The canonical pattern here is TopicUser.change (topic_user.rb:77), which creates the row if missing and sets notifications_changed_at/notifications_reason_id. Use TopicUser.change(current_user.id, params[:topic_id], notification_level: ...) (or guard tu with find_or_initialize_by) instead of raw find_by + save!.

Comment thread config/routes.rb
get "t/:topic_id/summary" => "topics#show", constraints: {topic_id: /\d+/, post_number: /\d+/}
get "t/:slug/:topic_id/moderator-liked" => "topics#moderator_liked", constraints: {topic_id: /\d+/}
get "t/:slug/:topic_id/summary" => "topics#show", defaults: {summary: true}, constraints: {topic_id: /\d+/}
get "t/:slug/:topic_id/unsubscribe" => "topics#unsubscribe", constraints: {topic_id: /\d+/}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking — state-mutating GET route is a CSRF vector and is non-idempotent. unsubscribe is registered as get, but TopicsController#unsubscribe calls tu.save! to change notification_level. Rails CSRF protection does not cover GET requests, so any logged-in user can be silently made to mute/track topics by a cross-origin <img src="/t/:slug/:id/unsubscribe"> embedded anywhere they browse. Additionally the action toggles (watching/tracking -> regular, else -> muted), so link pre-fetching by browsers/email clients (Apple Mail, Outlook) and accidental refreshes silently mutate notification settings. Switch to a POST/DELETE route with a CSRF token (or a signed single-use token), and make the target a stable level rather than a toggle.

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.

find_by + save! bypasses TopicUser.change: it sets no notifications_changed_at / notifications_reason_id, publishes no MessageBus /topic/:id notification_level_change event (so other open tabs / the topic page won't reflect the change in real time), and the find->mutate->save has no transaction or locking — a double-click or two concurrent requests both read the same level and both write, collapsing two intended toggles into one. Prefer TopicUser.change, which wraps this in a transaction with a RecordNotUnique rescue.

@@ -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.

{{{stopNotificiationsText}}} renders raw HTML. The i18n string embeds <strong>{{title}}</strong> and title is interpolated from model.fancyTitle (user-controlled topic title). It is safe today only because fancy_title is server-escaped; triple-braces bypass Handlebars escaping, so any future change to fancyTitle sanitization becomes a stored-XSS sink. Prefer double-braces and restructure the i18n key to avoid embedded HTML, or sanitize fancyTitle in the controller before interpolation.

html_override.gsub!("%{respond_instructions}", respond_instructions)
end

unsubscribe_link = PrettyText.cook(I18n.t('unsubscribe_link', template_args)).html_safe

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I18n.t('unsubscribe_link', template_args) (here in html_part, and identically in body) now interpolates %{unsubscribe_url}, which only exists in template_args when the caller passes unsubscribe_url via @opts. Today only UserNotifications#send_notification_email does, so nothing breaks — but the unsubscribe_link translation contract is now tighter and undocumented; any other/future caller setting add_unsubscribe_link: true without unsubscribe_url will raise I18n::MissingInterpolationArgument at send time. Either default unsubscribe_url in the builder or document the requirement.

raise ex
end

def 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.

No controller tests exist for the new unsubscribe action (spec/controllers/topics_controller_spec.rb has no unsubscribe context). This is a state-mutating, auth-gated, security-sensitive endpoint — please add coverage for: the notification_level mutation, the slugs_do_not_match/blank-slug 301 redirect, the authenticated-access requirement, and the perform_show_response render path.

body: 'hello world',
add_unsubscribe_link: true) }
add_unsubscribe_link: true,
unsubscribe_url: "/t/1234/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.

The add_unsubscribe_link: true context now passes unsubscribe_url: "/t/1234/unsubscribe", but the assertions only check the List-Unsubscribe header and that user_preferences_url is in the body — they never assert the per-topic unsubscribe URL actually renders. The core feature this PR adds (the per-topic link in the email body) is therefore untested. Add something like expect(message_with_unsubscribe.body).to match("/t/1234/unsubscribe").

export default Discourse.Route.extend({
model(params) {
const topic = this.store.createRecord("topic", { id: params.id });
return PostStream.loadTopicView(params.id).then(json => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The model hook returns PostStream.loadTopicView(params.id).then(...) with no .catch. After the server-rendered page loads, a 404/403/network failure rejects the promise and falls through to Ember's generic error handler — the user sees a generic 'Oops' page with no context that the unsubscribe action failed. Add rejection handling that surfaces an unsubscribe-specific message.


export default ObjectController.extend({

stopNotificiationsText: function() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Typo in the computed property name: stopNotificiationsText -> stopNotificationsText (extra i, missing a). The template at topic/unsubscribe.hbs:3 references the same misspelled name so it works today, but the misspelling will trip up future maintainers/translators. Fix in both places together.

@ron-x5labs ron-x5labs 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.

Code Review: Test 2

Problem

The PR description ("Test 2") is empty, so the goal is inferred from the diff: add a per-topic unsubscribe option to notification emails — a link in the email body that lands the user on an in-app topic unsubscribe page which sets their notification level for that topic.

Solution Reviewed

  • Topic#unsubscribe_url ("#{url}/unsubscribe") threaded into UserNotifications#send_notification_email and the unsubscribe_link i18n string.
  • New TopicsController#unsubscribe GET action: canonicalizes the slug via 301, then toggles the current user's TopicUser#notification_level (watching/tracking -> regular, else -> muted) via find_by + save!, then renders the topic show view.
  • Ember client route/controller/view/template (topicUnsubscribe at /t/:slug/:id/unsubscribe) using new client.en.yml topic.unsubscribe.* keys; dropdown-button guards an empty title.
  • notification.html.erb, message_builder.rb, and server.en.yml restructured; message_builder_spec.rb updated to pass unsubscribe_url.

Summary

The feature is wired end-to-end, but the server unsubscribe action has two blocking defects: a nil crash on the exact email-link path for users without a TopicUser row, and a state-mutating, tokenless GET route that is a CSRF vector and non-idempotent. Several non-blocking issues remain around real-time sync, error handling, raw-HTML rendering, a tightened i18n contract, and missing test coverage. Recommend changes before merge.

Files Reviewed

  • app/controllers/topics_controller.rb — deeply reviewed (high risk: new state-mutating action)
  • config/routes.rb — deeply reviewed (GET route mapped to a mutating action)
  • app/models/topic.rb, app/models/topic_user.rb — reviewed for unsubscribe_url and the TopicUser.change contract
  • app/mailers/user_notifications.rb, lib/email/message_builder.rb — reviewed for the i18n contract
  • app/views/email/notification.html.erb, config/locales/server.en.yml — reviewed for email rendering
  • app/assets/javascripts/discourse/** (route/controller/template/view, dropdown-button, topic-from-params) — reviewed; Ember deps (ObjectController, PostStream.loadTopicView, topic-notifications-button) verified present
  • config/locales/client.en.yml — i18n keys consistent with usage
  • spec/components/email/message_builder_spec.rb — reviewed; coverage gap found

Verification

  • Static source-context verification: confirmed PostStream.loadTopicView exists and is the default export; confirmed ObjectController exists; confirmed fancy_title server-escapes the title; confirmed send_notification_email is the only in-repo caller setting add_unsubscribe_link: true and that it passes unsubscribe_url; confirmed routes match between Rails and Ember.
  • Ruby syntax check / spec suite: skipped (no Ruby/bundler environment available in the review sandbox).

Issues Found

🔴 Blocking

  • config/routes.rb:440 — state-mutating, tokenless GET unsubscribe route: CSRF vector + non-idempotent toggle (see inline).
  • app/controllers/topics_controller.rb:105TopicUser.find_by returns nil -> tu.notification_level raises NoMethodError -> 500 on the email-link path (see inline).

🟡 Non-blocking

  • app/controllers/topics_controller.rb:113save! bypasses TopicUser.change: no MessageBus event / audit (see inline).
  • app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs:3 — triple-brace raw-HTML render is a latent XSS sink (see inline).
  • app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6:6 — model promise has no rejection handler (see inline).
  • lib/email/message_builder.rb:67unsubscribe_link i18n now requires %{unsubscribe_url} with no default (see inline).
  • app/controllers/topics_controller.rb:98 — no controller spec for the new security-sensitive action (see inline).
  • spec/components/email/message_builder_spec.rb:172 — assertions don't verify the per-topic URL renders (see inline).

💡 Suggestions

  • app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6:5stopNotificiationsText typo (see inline).

Verdict

Recommend changes before merge — fix the nil crash and the CSRF/non-idempotent GET route (switch to a token-gated POST/DELETE or a signed single-use token with a fixed target level) before this ships.

Comment thread config/routes.rb
get "t/:topic_id/summary" => "topics#show", constraints: {topic_id: /\d+/, post_number: /\d+/}
get "t/:slug/:topic_id/moderator-liked" => "topics#moderator_liked", constraints: {topic_id: /\d+/}
get "t/:slug/:topic_id/summary" => "topics#show", defaults: {summary: true}, constraints: {topic_id: /\d+/}
get "t/:slug/:topic_id/unsubscribe" => "topics#unsubscribe", constraints: {topic_id: /\d+/}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking — state-mutating, tokenless GET route is a CSRF vector and is non-idempotent. unsubscribe is registered as get, but TopicsController#unsubscribe calls tu.save! to change notification_level. Rails protect_from_forgery does not cover GET requests, and check_xhr is explicitly skipped for unsubscribe, so a cross-origin <img src="/t/:slug/:id/unsubscribe"> embedded anywhere a logged-in user browses silently mutes their topics. The action toggles (watching/tracking -> regular, else -> muted), so mail-client/browser pre-fetching (Apple Mail, Outlook) and an accidental refresh re-flip the level. The existing secure pattern is EmailController's token-gated email/unsubscribe/:key (DigestUnsubscribeKey.user_for_key).

Suggested fix: use a POST/DELETE route with a CSRF token (or a signed single-use token in the URL), and set a fixed target level instead of toggling:

# routes.rb
post "t/:slug/:topic_id/unsubscribe" => "topics#unsubscribe", constraints: { topic_id: /\d+/ }
# topics_controller.rb — set a stable level, don't toggle
TopicUser.change(current_user.id, params[:topic_id], notification_level: TopicUser.notification_levels[:muted])

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])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking — nil deref crashes the unsubscribe link for users without a TopicUser record. TopicUser.find_by returns nil when the user has no per-topic row (e.g. they were only @mentioned, are watching via a group/category, or hit the URL directly without ever visiting). The very next line (tu.notification_level) then raises NoMethodError -> 500 on the unsubscribe page — the exact action the email link triggers. The canonical pattern is TopicUser.change (topic_user.rb), which upserts the row if missing and sets notifications_changed_at/notifications_reason_id.

target_level = ->(tu) { tu&.notification_level.to_i > TopicUser.notification_levels[:regular] ? :regular : :muted }
TopicUser.change(current_user.id, params[:topic_id], notification_level: TopicUser.notification_levels[target_level.call(tu)])

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.

find_by + save! bypasses TopicUser.change, so even when the row exists: (1) no MessageBus.publish("/topic/#{id}", {notification_level_change: ...}) is emitted, so other open tabs / the topic page won't reflect the change in real time; (2) no notifications_changed_at / notifications_reason_id audit is set; (3) the read-modify-write has no transaction or row locking, so a double-click or two concurrent requests both read the same level and collapse two intended toggles into one. TopicUser.change wraps this in a transaction with a RecordNotUnique rescue and publishes the MessageBus event — prefer it over raw find_by + save!.

@@ -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.

{{{stopNotificiationsText}}} renders raw HTML. The i18n string embeds <strong>{{title}}</strong> and title is interpolated from model.fancyTitle (user-controlled topic title). It is safe today only because Topic#fancy_title server-escapes via ERB::Util.html_escape before client emoji-unescaping; triple-braces bypass Handlebars escaping, so any future change to fancyTitle sanitization becomes a stored-XSS sink. Prefer double-braces and move the <strong> formatting into the template / a computed that escapes, or restructure the i18n key to avoid embedding HTML:

<p>{{stopNotificationsText}}</p>
stopNotificationsText: function() {
  return I18n.t("topic.unsubscribe.stop_notifications", { title: this.get("model.fancyTitle") });
}.property("model.fancyTitle"),

export default Discourse.Route.extend({
model(params) {
const topic = this.store.createRecord("topic", { id: params.id });
return PostStream.loadTopicView(params.id).then(json => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The model hook returns PostStream.loadTopicView(params.id).then(...) with no .catch. After the server-rendered page loads, a 404/403/network failure rejects the promise and falls through to Ember's generic error handler — the user sees a generic 'Oops' page with no indication the unsubscribe action failed. Add rejection handling (or an actions.error hook) that surfaces an unsubscribe-specific message:

return PostStream.loadTopicView(params.id).then(json => {
  topic.updateFromJson(json);
  return topic;
}).catch(() => {
  this.controllerFor("application").set("showFooter", true);
  // surface an unsubscribe-specific error to the user
});

html_override.gsub!("%{respond_instructions}", respond_instructions)
end

unsubscribe_link = PrettyText.cook(I18n.t('unsubscribe_link', template_args)).html_safe

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The unsubscribe_link i18n contract is now tighter and undocumented. I18n.t('unsubscribe_link', template_args) (here in html_part, and identically in body) now interpolates %{unsubscribe_url}, which only exists in template_args when the caller passes unsubscribe_url via @opts. Today only UserNotifications#send_notification_email does, so nothing breaks — but any future/plugin caller setting add_unsubscribe_link: true without unsubscribe_url will raise I18n::MissingInterpolationArgument at send time, and non-English locales missing the new line silently omit the per-topic link. Either default unsubscribe_url in the builder or document the requirement:

@template_args = {
  site_name: SiteSetting.email_prefix.presence || SiteSetting.title,
  base_url: Discourse.base_url,
  user_preferences_url: "#{Discourse.base_url}/my/preferences",
  unsubscribe_url: "",
}.merge!(@opts)

raise ex
end

def 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.

No controller tests exist for the new unsubscribe action (spec/controllers/topics_controller_spec.rb has no unsubscribe context). This is a state-mutating, auth-gated, security-sensitive endpoint — please add coverage for: the notification_level mutation, the slugs_do_not_match/blank-slug 301 redirect, the authenticated-access requirement (ensure_logged_in), and the perform_show_response render path (including the failure case where no TopicUser row exists).

body: 'hello world',
add_unsubscribe_link: true) }
add_unsubscribe_link: true,
unsubscribe_url: "/t/1234/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.

The core feature this PR adds (the per-topic unsubscribe link in the email body) is untested. The builder is now constructed with unsubscribe_url: "/t/1234/unsubscribe", but the assertions only check the List-Unsubscribe header and that user_preferences_url is in the body — they never assert the per-topic URL renders. Add:

it "includes the per-topic unsubscribe url in the body" do
  expect(message_with_unsubscribe.body).to match("/t/1234/unsubscribe")
end


export default ObjectController.extend({

stopNotificiationsText: function() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Typo in the computed property name: stopNotificiationsText -> stopNotificationsText (extra i, missing a). The template at topic/unsubscribe.hbs:3 references the same misspelled name so it works today, but the misspelling will trip up future maintainers/translators. Fix in both places together:

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

@ron-x5labs ron-x5labs 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.

Code Review: Test 2

Problem

The PR description ("Test 2") is empty, so the goal is inferred from the diff: add a per-topic unsubscribe option to notification emails — an unsubscribe link in the email body that lands the user on an in-app topic page which changes their notification level for that topic.

Solution Reviewed

  • New Topic#unsubscribe_url ("#{url}/unsubscribe") threaded into UserNotifications#send_notification_email and the unsubscribe_link i18n string.
  • New TopicsController#unsubscribe GET action that toggles the current user's TopicUser#notification_level (watching/tracking → regular, otherwise → muted) via find_by + save!, then renders the topic show view.
  • Ember client: topic-unsubscribe route/controller/template/view, app-route-map entry, dropdown-button null-guard, topic-from-params ES6 cleanup, locales, and SCSS.

Summary

The feature is wired end-to-end (routes, mailer, builder, i18n, email template, Ember), but the new unsubscribe controller action has two blocking defects: a nil-deref crash on a reachable path and a state-mutating, CSRF-vulnerable, non-idempotent GET route. Several test-coverage gaps for the new state-mutating endpoint round out the review. Recommend changes before merge.

Files Reviewed

  • app/controllers/topics_controller.rb — deeply reviewed (new unsubscribe action: nil-deref, bypasses TopicUser.change, CSRF via GET)
  • config/routes.rb — deeply reviewed (GET unsubscribe route = CSRF vector)
  • app/models/topic.rb — deeply reviewed (unsubscribe_url is correct)
  • app/models/topic_user.rb — deeply reviewed (verified change contract that unsubscribe bypasses)
  • lib/email/message_builder.rb — deeply reviewed (i18n interpolation path; in-tree callers all wire unsubscribe_url)
  • app/mailers/user_notifications.rb — deeply reviewed
  • app/views/email/notification.html.erb — lightly reviewed (template/formatting only)
  • app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 — deeply reviewed (missing promise rejection handling)
  • app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6 — deeply reviewed (typo)
  • app/assets/javascripts/discourse/templates/topic/unsubscribe.hbs — lightly reviewed (triple-brace safe: fancy_title is server-escaped)
  • app/assets/javascripts/discourse/components/dropdown-button.js.es6 — lightly reviewed (null-guard is correct)
  • app/assets/javascripts/discourse/routes/topic-from-params.js.es6 — lightly reviewed (ES6 refactor, no behavior change)
  • app/assets/javascripts/discourse/routes/app-route-map.js.es6 — lightly reviewed (route entry correct)
  • app/assets/javascripts/discourse/views/topic-unsubscribe.js.es6 — lightly reviewed (trivial)
  • app/assets/stylesheets/common/base/topic.scss — lightly reviewed (CSS only)
  • config/locales/client.en.yml, config/locales/server.en.yml — lightly reviewed (i18n keys consistent)
  • spec/components/email/message_builder_spec.rb — deeply reviewed (assertion gap)

Verification

  • Ruby syntax/typecheck — skipped: no Ruby toolchain in the review environment.
  • Specialist passes: security (blind), data/edge-case+state, error-handling+integration, tests/intent — all run.

Verdict

Recommend changes before merge — the unsubscribe action crashes on a reachable path and its GET route is a CSRF vector; both are straightforward to fix with TopicUser.change and a token-gated POST/PUT.


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

if tu.notification_level > TopicUser.notification_levels[:regular]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking — nil deref crashes the unsubscribe link for users without a TopicUser row. TopicUser.find_by (line 105) returns nil whenever the user has no per-topic record (mentioned-only, group/category watch, or a direct URL hit), and this line dereferences tu.notification_level, raising NoMethodError → 500 on the exact page the email link opens. Even when the row exists, raw find_by + save! bypasses TopicUser.change (topic_user.rb:77), which wraps the write in a transaction, sets notifications_changed_at/notifications_reason_id, publishes the /topic/:id MessageBus notification_level_change event (so other tabs update live), and rescues RecordNotUnique. Replace the whole block with the canonical upsert:

target = tu&.notification_level.to_i > TopicUser.notification_levels[:regular] ? :regular : :muted
TopicUser.change(current_user.id, params[:topic_id], notification_level: target)

Comment thread config/routes.rb
get "t/:topic_id/summary" => "topics#show", constraints: {topic_id: /\d+/, post_number: /\d+/}
get "t/:slug/:topic_id/moderator-liked" => "topics#moderator_liked", constraints: {topic_id: /\d+/}
get "t/:slug/:topic_id/summary" => "topics#show", defaults: {summary: true}, constraints: {topic_id: /\d+/}
get "t/:slug/:topic_id/unsubscribe" => "topics#unsubscribe", constraints: {topic_id: /\d+/}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking — state-mutating GET route is a CSRF vector and is non-idempotent. unsubscribe is registered as get but the action calls tu.save! to change notification_level. Rails CSRF protection does not cover GET, check_xhr is explicitly skipped for unsubscribe, and ensure_logged_in only checks the session cookie — which browsers attach automatically to cross-origin <img src="/t/123/unsubscribe"> requests — so any page a logged-in user visits can silently flip their notification level on arbitrary topics. The action also toggles (watching/tracking → regular, else → muted), so mail-client/browser pre-fetching or an accidental refresh re-flips the level (unsubscribe → resubscribe). The sibling mute/unmute routes below use put for exactly this reason — switch to a token-gated POST/PUT (or a signed single-use token in the URL) and make the operation idempotent (always set :muted, don't toggle).

export default Discourse.Route.extend({
model(params) {
const topic = this.store.createRecord("topic", { id: params.id });
return PostStream.loadTopicView(params.id).then(json => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PostStream.loadTopicView(...) rejects on 404 (deleted topic), 403 (no access), or network failure, but the model hook has no .catch and afterModel then calls topic.set("details.notificationReasonText", null) on a topic that never loaded — the user sees a generic Ember error page with no indication the unsubscribe failed. Add rejection handling (or an actions.error hook) that surfaces an unsubscribe-specific message:

return PostStream.loadTopicView(params.id).then(json => {
  topic.updateFromJson(json);
  return topic;
}).catch(() => {
  this.controllerFor("application").set("showFooter", true);
  // surface an unsubscribe-specific error to the user
});

raise ex
end

def 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.

The new unsubscribe action is state-mutating, auth-gated, and non-idempotent, but spec/controllers/topics_controller_spec.rb has no unsubscribe context (the file has extensive contexts for sibling actions like show). Please add coverage for: the notification_level toggle, the slugs_do_not_match/blank-slug 301 redirect, the authenticated-access requirement (ensure_logged_in), and the missing-TopicUser-row crash case.

body: 'hello world',
add_unsubscribe_link: true) }
add_unsubscribe_link: true,
unsubscribe_url: "/t/1234/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.

The builder is now constructed with unsubscribe_url: "/t/1234/unsubscribe", but the existing tests only assert the List-Unsubscribe header and that user_preferences_url appears in the body — they never assert the per-topic unsubscribe URL renders. The core feature this PR adds (the per-topic link in the email body) is therefore untested. Add:

it "includes the per-topic unsubscribe url in the body" do
  expect(message_with_unsubscribe.body).to match("/t/1234/unsubscribe")
end


export default ObjectController.extend({

stopNotificiationsText: function() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Typo in the computed property name: stopNotificiationsText has an extra i (should be stopNotificationsText). The template at topic/unsubscribe.hbs:3 references the same misspelled name so it works today, but fix both together to avoid confusing future maintainers:

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

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.

send_notification_email now passes unsubscribe_url: post.topic.unsubscribe_url, but spec/mailers/user_notifications_spec.rb only asserts has_key(:add_unsubscribe_link) and never that unsubscribe_url is forwarded. If this opt were dropped, the unsubscribe_link i18n string (which now requires %{unsubscribe_url}) would raise I18n::MissingInterpolationArgument only at email-send time, with no spec catching it. Add expects_build_with(has_key(:unsubscribe_url)) beside the existing assertions.

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.

5 participants