Orangered emails: one-click unsubscribe

Even if it's opt-in, we want people to be able to easily unsubscribe from
notification emails.

Using an HMAC instead of a generated token means we don't have to store
anything extra, but just perform a calculation on email send and in the
unsubscribe responder.
This commit is contained in:
xiongchiamiov
2014-12-18 17:14:52 -08:00
parent 73a7d237bd
commit a110a9d870
8 changed files with 95 additions and 2 deletions

View File

@@ -25,6 +25,8 @@ media_embed = YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5
comment_embed = YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5
# secret for authenticating controller#action name
action_name = YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5
# secret for email notification one-click unsubscribe links
email_notifications = YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5
# secrets for communicating with Stripe (optional payment processor)
stripe_webhook =
stripe_public_key =

View File

@@ -217,6 +217,8 @@ def make_map():
mc('/mail/optout', controller='forms', action='optout')
mc('/mail/optin', controller='forms', action='optin')
mc('/mail/unsubscribe/:user/:key', controller='forms',
action='unsubscribe_emails')
mc('/stylesheet', controller='front', action='stylesheet')
mc('/frame', controller='front', action='frame')
mc('/framebuster/:blah', controller='front', action='framebuster')

View File

@@ -36,7 +36,7 @@ from r2 import config
from r2.models import *
from r2.models.recommend import ExploreSettings
from r2.config.extensions import is_api
from r2.lib import recommender, embeds
from r2.lib import hooks, recommender, embeds
from r2.lib.pages import *
from r2.lib.pages.things import hot_links_by_url_listing
from r2.lib.pages import trafficpages
@@ -47,7 +47,7 @@ from r2.lib.utils import to36, sanitize_url, title_to_url
from r2.lib.utils import query_string, UrlParser, url_links_builder
from r2.lib.template_helpers import get_domain
from r2.lib.filters import unsafe, _force_unicode, _force_utf8
from r2.lib.emailer import Email
from r2.lib.emailer import Email, generate_notification_email_unsubscribe_token
from r2.lib.db.operators import desc
from r2.lib.db import queries
from r2.lib.db.tdb_cassandra import MultiColumnQuery
@@ -1355,6 +1355,28 @@ class FormsController(RedditController):
)
).render()
@validate(
user_id36=nop('user'),
provided_mac=nop('key')
)
def GET_unsubscribe_emails(self, user_id36, provided_mac):
from r2.lib.utils import constant_time_compare
expected_mac = generate_notification_email_unsubscribe_token(user_id36)
if not constant_time_compare(provided_mac or '', expected_mac):
error_page = pages.RedditError(
title=_('incorrect message token'),
message='',
)
request.environ["usable_error_content"] = error_page.render()
self.abort404()
user = Account._byID36(user_id36, data=True)
user.pref_email_messages = False
user._commit()
return BoringPage(_('emails unsubscribed'),
content=MessageNotificationEmailsUnsubscribe()).render()
@disable_subreddit_css()
@validate(VUser(),
location=nop("location"),

View File

@@ -142,9 +142,16 @@ def message_notification_email(data):
raise Exception(
'Message notification emails: safety limit exceeded!')
mac = generate_notification_email_unsubscribe_token(
datum['to'], user_email=user.email,
user_password_hash=user.password)
base = g.https_endpoint or g.origin
unsubscribe_link = base + '/mail/unsubscribe/%s/%s' % (datum['to'], mac)
templateData = {
'comment': comment,
'permalink': datum['permalink'],
'unsubscribe_link': unsubscribe_link,
}
_system_email(user.email,
MessageNotificationEmail(**templateData).render(style='email'),
@@ -154,6 +161,31 @@ def message_notification_email(data):
g.stats.simple_event('email.message_notification.queued')
g.cache.incr(MESSAGE_THROTTLE_KEY)
def generate_notification_email_unsubscribe_token(user_id36, user_email=None,
user_password_hash=None):
"""Generate a token used for one-click unsubscribe links for notification
emails.
user_id36: A base36-encoded user id.
user_email: The user's email. Looked up if not provided.
user_password_hash: The hash of the user's password. Looked up if not
provided.
"""
import hashlib
import hmac
if (not user_email) or (not user_password_hash):
user = Account._byID36(user_id36, data=True)
if not user_email:
user_email = user.email
if not user_password_hash:
user_password_hash = user.password
return hmac.new(
g.secrets['email_notifications'],
user_id36 + user_email + user_password_hash,
hashlib.sha256).hexdigest()
def password_change_email(user):
"""Queues a system email for a password change notification."""
from r2.lib.pages import PasswordChangeEmail

View File

@@ -2781,6 +2781,11 @@ class MessageNotificationEmail(Templated):
"""Notification e-mail that a user has received a new message."""
pass
class MessageNotificationEmailsUnsubscribe(Templated):
"""The page we show users when they unsubscribe from notification
emails."""
pass
class PasswordChangeEmail(Templated):
"""Notification e-mail that a user's password has changed."""
pass

View File

@@ -1279,6 +1279,9 @@ def constant_time_compare(actual, expected):
The time taken is dependent on the number of characters provided
instead of the number of characters that match.
When we upgrade to Python 2.7.7 or newer, we should use hmac.compare_digest
instead.
"""
actual_len = len(actual)
expected_len = len(expected)

View File

@@ -29,3 +29,5 @@ ${_("You've received a new message!")}
${unsafe(thing.comment.body)}
${_("View on the web")}: ${thing.permalink}
${_("Unsubscribe")}: ${thing.unsubscribe_link}

View File

@@ -0,0 +1,25 @@
## The contents of this file are subject to the Common Public Attribution
## License Version 1.0. (the "License"); you may not use this file except in
## compliance with the License. You may obtain a copy of the License at
## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
## License Version 1.1, but Sections 14 and 15 have been added to cover use of
## software over a computer network and provide for limited attribution for the
## Original Developer. In addition, Exhibit A has been modified to be
## consistent with Exhibit B.
##
## Software distributed under the License is distributed on an "AS IS" basis,
## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
## the specific language governing rights and limitations under the License.
##
## The Original Code is reddit.
##
## The Original Developer is the Initial Developer. The Initial Developer of
## the Original Code is reddit Inc.
##
## All portions of the code written by reddit are Copyright (c) 2006-2015
## reddit Inc. All Rights Reserved.
###############################################################################
<div style="text-align: center">
<p>${_("Thanks! You're unsubscribed from all message notification emails.")}</p>
</div>