FEATURE: per-topic unsubscribe option in emails
discourse/discourse@6669a2d9Golden Comments (1/2 found)
| # | Comment | Severity | Status | FriendlyReviewer Detail |
|---|---|---|---|---|
| #1 | logic: Potential nil pointer exception - if no TopicUser record exists, tu will be nil and calling methods on it will crash |
HIGH | ✓ Found | "TopicUser.find_by(user_id: ..., topic_id: ...) can legitimately return nil (e.g. forwarded email, topic never visited, regular notification), and if tu.notification_level > ... then raises NoMethodError. Prefer TopicUser.change(...) (which upserts the row) or guard tu explicitly" (app/controllers/topics_controller.rb L105) |
| #2 | Typo in property name: 'stopNotificiationsText' should be 'stopNotificationsText' (missing 'n' in 'Notifications') |
LOW | ✗ Not found | No candidate mentions the typo. The review's 8 low findings all target other concerns — the List-Unsubscribe header dropping the per-topic URL, the 301 on a mutating GET, the unguarded TopicView.new, the cosmetic topic_user.rb diff, the structural template change, the mobile CSS inconsistency, and two locale strings — but none of them names stopNotificiationsText. The typo lives in client-side code the review did not inspect closely. See app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6 |
Supplementary Findings (13 findings)
| Finding | File | Severity | Legitimate? |
|---|---|---|---|
unsubscribe is a state-changing GET that skips check_xhr; Rails CSRF verification only applies to non-GET by default, so a malicious page can force a logged-in victim to unsubscribe/mute from arbitrary topics via an image/link |
app/controllers/topics_controller.rb:98 | HIGH | ✓ Real: a mutating GET with no CSRF protection is a genuine security issue on a logged-in action |
%{unsubscribe_url} becomes an implicit required argument of the unsubscribe_link locale string — Rails raises I18n::MissingInterpolationArgument if a caller omits it; the contract is fragile and undocumented |
lib/email/message_builder.rb:90 | MEDIUM | ✓ Real: a latent breaking contract; nothing is broken today only because the single caller always passes the value |
unsubscribe_url returns a plain topic URL with no token/key, so the per-topic email link only works for a logged-in, currently-authenticated user — inconsistent with the app's established key-based unsubscribe mechanism |
app/models/topic.rb:719 | MEDIUM | ✓ Real: a design gap for the feature's core use case (clicking from a mail client without a session) |
The new per-topic unsubscribe mechanism is not asserted by the spec: there is no check that the body/html contains the supplied unsubscribe_url (/t/1234/unsubscribe), so the feature this MR adds is untested |
spec/components/email/message_builder_spec.rb:178 | MEDIUM | ✓ Real: the feature is delivered without test coverage, and the existing assertion mixes two builder instances |
The action writes tu.notification_level directly and calls tu.save!, bypassing TopicUser.change which sets notifications_changed_at, creates the row when absent, and publishes the MessageBus notification_level_change event — so the open topic page keeps a stale badge |
app/controllers/topics_controller.rb:107 | MEDIUM | ✓ Real: bypassing the canonical API means the client never learns of the change; stale UI state |
The List-Unsubscribe header always points at the global user_preferences_url and drops the per-topic unsubscribe_url entirely; the new spec test only checks .to be_present, not the value |
lib/email/message_builder.rb:107 | LOW | ✓ Real: may be intentional (RFC list-level header) but undocumented and arguably at odds with the feature title |
redirect_to @topic_view.topic.unsubscribe_url, status: 301 issues Moved Permanently for a URL whose effect is a state change; 301 is cacheable by browsers/intermediaries and semantically wrong for a mutating GET |
app/controllers/topics_controller.rb:102 | LOW | ✓ Real: a 301 on a state-changing endpoint can be cached and replayed; 302/303 is correct |
TopicView.new(params[:topic_id], current_user) is not guarded like the show action's begin/rescue Discourse::NotFound; a non-existent or unviewable topic raises instead of a clean 404 |
app/controllers/topics_controller.rb:99 | LOW | ✓ Real: inconsistent error handling with the sibling show action |
The diff to topic_user.rb is purely cosmetic (whitespace/formatting) with no functional change, adding diff noise to a feature MR whose blast radius is large |
app/models/topic_user.rb:10 | LOW | ✓ Borderline: cosmetic reformatting noise, a legitimate review nit for a feature MR |
The template change is structural/formatting only; the per-topic link still depends on the %{unsubscribe_link} placeholder. Pre-existing quirk: when context_posts.present?, %{respond_instructions} is emitted twice (both gsub'd, so not a rendering bug) |
app/views/email/notification.html.erb:17 | LOW | ✓ Borderline: awareness-only, pre-existing duplication — not introduced by this PR |
The new rule sets .topic-unsubscribe .notification-options { display: inline-block; } but does not neutralize the mobile base rule (clear: both; float: left); display and float are independent, so the control is floated on mobile vs inline-block on desktop |
app/assets/stylesheets/common/base/topic.scss:69 | LOW | ✓ Real: a cross-viewport inconsistency; the override leaves the mobile float in place |
change_notification_state omits the trailing period that its sibling stop_notifications string has |
config/locales/client.en.yml:986 | LOW | ✓ Borderline: cosmetic consistency fix only |
The new plain-text line is appended verbatim (un-cooked markdown [click here](...)) to the body, doubling the raw-markdown-in-text exposure (mirrors the pre-existing user-preferences line); may also break hardcoded email fixtures that assert the old single-line text |
config/locales/server.en.yml:1852 | LOW | ✓ Real: raw markdown in a plain-text email, plus a plausible fixture breakage the review calls out |
Honest Analysis
FriendlyReviewer's full review
As posted on the PR — the complete output from FriendlyReviewer.
Hi there 👋
| 🌥️ Tech | 🌥️ Feat |
|---|---|
| 2 high, 4 medium, 8 low | 1 issue, 1 mitigated |
The MR implements a per-topic unsubscribe option in notification emails: Topic#unsubscribe_url is threaded through MessageBuilder#template_args (via the unsubscribe_url opt) and rendered as a per-topic link in both the HTML and plain-text bodies, with matching routes (t/:topic_id/unsubscribe and slug variant), a new topics#unsubscribe controller action, client/server locale strings, and some scoped unsubscribe-page CSS. Routing, locale YAML validity, and the CSS specificity for desktop are correct.
However, the review found several blocking concerns, concentrated in the new controller endpoint: it can raise NoMethodError when no TopicUser row exists, it mutates the notification level directly instead of going through TopicUser.change (so the MessageBus event the client relies on is never published and the open topic page keeps a stale badge), and it performs the state change via a CSRF-exposed GET. In addition, the per-topic unsubscribe_url carries no signed key and requires an authenticated session, which is inconsistent with the app's other (key-based) unsubscribe links. The spec does not actually assert the new per-topic link is rendered, and the unsubscribe_link locale string now implicitly hard-requires %{unsubscribe_url} for any caller enabling add_unsubscribe_link.
Note: one section of the review (mailer/model/view wiring, incl. user_notifications.rb, topic.rb, topic_user.rb, and notification.html.erb) was delivered in partial form and may be less complete/verified than the rest; its findings are included but should be re-confirmed.
Because the controller endpoint has blocking correctness and security issues, this MR should not be merged as-is.
These might need a close look
- 🔴 app/controllers/topics_controller.rb (L105)
TopicUser.find_by(user_id: ..., topic_id: ...)can legitimately returnnil(e.g. forwarded email, topic never visited, regular notification), andif tu.notification_level > ...then raisesNoMethodError. PreferTopicUser.change(...)(which upserts the row) or guardtuexplicitly. - 🔴 app/controllers/topics_controller.rb (L98)
unsubscribeis a state-changing GET that skipscheck_xhr; Rails CSRF verification only applies to non-GET by default, so a malicious page can force a logged-in victim to unsubscribe/mute from arbitrary topics via an image/link. Switch to POST/PUT (matchingset_notifications/mute) with an authenticity token, or at minimum requirecheck_xhr/ an explicit token.
Worth checking
- 🟡 lib/email/message_builder.rb (L90)
Theunsubscribe_linklocale string now contains%{unsubscribe_url}, andMessageBuilderinterpolates it in bothbody(line 90) andhtml_part(line 67) wheneveradd_unsubscribe_linkis true.%{unsubscribe_url}thus becomes an implicit required argument — Rails raisesI18n::MissingInterpolationArgumentif a caller omits it. Today onlyuser_notifications.rbsets this opt and always passes the value, so nothing is currently broken, but the contract is fragile and undocumented. Guard the interpolation (only render the per-topic sentence whenunsubscribe_urlis present) or split the locale string into a global prefs string and a per-topic supplement, and cover theadd_unsubscribe_link: truewithoutunsubscribe_urlcase in the spec. - 🟡 app/models/topic.rb (L719)
unsubscribe_urlreturns a plain topic URL with no token/key, so the per-topic email link only works for a logged-in, currently-authenticated user; clicking from a mail client without a session bounces to login and loses the unsubscribe context. This is inconsistent with the app's established unsubscribe mechanism (e.g. digest usesemail_unsubscribe_url(key: ...)). Confirm the design and document it, or emit a signed key and handle the non-authenticated / no-TopicUserstate gracefully. - 🟡 spec/components/email/message_builder_spec.rb (L178)
The new per-topic unsubscribe mechanism is not asserted by the spec: there is no check that the body/html contains the suppliedunsubscribe_url(/t/1234/unsubscribe), so the feature this MR adds is untested. Additionally,expect(message_with_unsubscribe.body).to match(builder.template_args[:user_preferences_url])mixes two builder instances; usemessage_with_unsubscribe.template_args[:user_preferences_url]for clarity (it only passes today becauseuser_preferences_urlis a constant derived fromDiscourse.base_url). - 🟡 app/controllers/topics_controller.rb (L107)
The action writestu.notification_leveldirectly and callstu.save!, bypassing the canonicalTopicUser.changeAPI which (a) setsnotifications_changed_at/notifications_reason_id, (b) creates the row when absent, and (c) publishes the MessageBus/topic/#{topic_id}notification_level_changeevent — so the open topic page keeps a stale badge/state. UseTopicUser.change(current_user, params[:topic_id], notification_level: new_level). Also note the toggle semantics (atrackinguser is only dropped toregular) may not match the email copy promising to stop notifications.
Small things (take or leave)
- 🔵 lib/email/message_builder.rb (L107)
TheList-Unsubscribeheader always points at the globaluser_preferences_urland drops the per-topicunsubscribe_urlentirely; the new spec test only checks.to be_present, not the value. This may be intentional (RFC list-level header), but it should be documented or made per-topic given the feature title. - 🔵 app/controllers/topics_controller.rb (L102)
redirect_to @topic_view.topic.unsubscribe_url, status: 301issues Moved Permanently for a URL whose effect is a state change. 301 is cacheable by browsers/intermediaries and is semantically wrong for a mutating GET; 302/303 See Other is more appropriate. - 🔵 app/controllers/topics_controller.rb (L99)
TopicView.new(params[:topic_id], current_user)is not guarded like theshowaction'sbegin/rescue Discourse::NotFound; for a non-existent or unviewable topic it raisesDiscourse::NotFound/Discourse::InvalidAccessand relies on the global handler instead of a clean 404/invalid-access response. - 🔵 app/models/topic_user.rb (L10)
The diff totopic_user.rbis purely cosmetic (whitespace/formatting) with no functional change, adding diff noise to a feature MR whose blast radius is large. Recommend keeping such reformatting out of feature MRs or splitting it into a separate commit. - 🔵 app/views/email/notification.html.erb (L17)
The template change is structural/formatting only; the per-topic link still depends on the%{unsubscribe_link}placeholder substituted inMessageBuilder#html_part. Pre-existing quirk (not introduced here): whencontext_posts.present?,%{respond_instructions}is emitted twice — both are gsub'd so not a rendering bug, but it is duplicated content. Awareness only. - 🔵 app/assets/stylesheets/common/base/topic.scss (L69)
The new rule sets.topic-unsubscribe .notification-options { display: inline-block; }but does not neutralize the mobile base rule (.notification-options { clear: both; float: left; margin-top: 0; padding-top: 1px; }inmobile/topic.scss:39).displayandfloatare independent, so the control is floated on mobile vs inline-block on desktop — a cross-viewport inconsistency. Resetfloat: none; clear: none;within the scope or document the intent. - 🔵 config/locales/client.en.yml (L986)
change_notification_stateomits the trailing period that its siblingstop_notificationsstring has; cosmetic consistency fix only. - 🔵 config/locales/server.en.yml (L1852)
The newTo stop receiving notifications about this particular topic, [click here](%{unsubscribe_url}).line is appended verbatim (un-cooked markdown[click here](...)) to the plain-textbody, doubling the raw-markdown-in-text exposure (mirrors the pre-existing user-preferences line). Also note this locale change may break hardcoded email fixtures (e.g.spec/fixtures/emails/*.eml,receiver_spec.rb) that assert the old single-line text.
✅ Feature-level checklist
Not there yet
- ❌ The new per-topic unsubscribe mechanism is covered by automated tests.
The spec does not assert that the per-topic link is rendered — it only checks the pre-existing globaluser_preferences_urlbody line, so the new mechanism itself is untested.
Partially covered
- ⚠️ Per-topic unsubscribe option is provided in notification emails (link rendered with an actionable destination).
The per-topicunsubscribe_urlis threaded through MessageBuilder and rendered in both HTML and plain-text bodies, with routing and a controller action implemented. However the link is unauthenticated/unkeyed (requires a logged-in session, inconsistent with the app's key-based unsubscribes) and the controller endpoint that processes it has correctness and CSRF issues, so the feature as delivered is only partially satisfactory.
FriendlyReviewer found 1 of the 2 golden comments — recall 50%. The golden it caught matters: the nil
TopicUserdereference (HIGH) is found precisely attopics_controller.rbL105, with the failure modes enumerated (forwarded email, never-visited topic, regular notification) and the right fix suggested (TopicUser.change, which upserts). That is the crash-in-production bug of this PR, and the review treats it as such, ranking it among its "close look" items.The missed golden is the typo
stopNotificiationsText. None of the 13 findings the review did produce mentions it — the eight low findings are all elsewhere: theList-Unsubscribeheader, the 301, the unguardedTopicView, a cosmetic diff, a template restructure, mobile CSS, and two locale strings. This is a characteristic miss: the review reasons about behavior and contracts, while a typo in a client-side property name is a string-level defect that only shows up by comparing the diff token by token. The golden is LOW severity, so the recall loss here is cheap, but it is a real category gap — a mechanical error in the very file the feature centers on (topic-unsubscribe.js.es6) went entirely unexamined.The supplementary findings are the strongest part of this run. The CSRF-exposed state-changing GET (HIGH) is arguably more important than the whole golden set combined: it lets any page force a logged-in victim to unsubscribe from arbitrary topics. The MessageBus finding (direct write instead of
TopicUser.changemeans the open topic page keeps a stale badge) shows cross-file reasoning — the review knows what the client depends on and checks whether the new endpoint feeds it. The untested-spec finding and the unkeyed-URL design gap are both legitimate feature-level objections. Only the two locale items and the cosmetic-diff item are genuinely minor.The review also disclosed, in its own words, that one section (mailer/model/view wiring) was delivered in partial form and should be re-confirmed — an honest flag, and relevant here: the missed golden sits precisely in that under-confirmed area. The verdict ("should not be merged as-is") is justified by the CSRF and nil-pointer issues regardless of the typo.