As the question says.
For things like this I use a separate controller with the user reference passed in query parameters as a Signed Global ID; e.g. in the mailer:
<%= link_to 'Unsubscribe', unsubscribe_url(u: @user.to_sgid_param(for: :unsubscribe_user)) %>
then in my UnsubscribeController actions, code like this:
user = SignedGlobalID.find(params[:u], for: :unsubscribe_user) if user&.subscribed? user.update(subscribed: false) # etc
This controller's actions are not otherwise authenticated i.e. no authorization via @user in before_actions, no checking for a valid session.
You should not read or modify any session data in the UnsubscribeController nor reveal any information whatsoever in its views. My views for this say "You were unsubscribed" on success, that's all. No names or addresses shown, nothing. I also prefer a plain layout. Here's why: you can trust the SGID to have come from you (otherwise the find method returns nil and you'll take them to an error page), but you can't trust that it was used by someone honest, because it was sent via email which is notoriously insecure. So don't leak information in the response.
Signed Global IDs (aka SGIDs) are little-known but they come with Rails and are built into Active Record models; they use your configured secret key to build a tamper-resistant object reference that can be passed to third parties for later reference back into your models. https://github.com/rails/globalid for more.
I use them for password reset links too, since they support expiry times and purpose restriction (which you should always use). They do not support replay protection, however; if you need that you'll need additional logic; so for the password reset links I consume a single-use token as well.
(I also use them for more arcane references to data objects by third-parties, e.g. in the metadata of Google Drive push notification channels, but that's a whole other story.)
Thanks, will give that a try.