From 0063fd6e88c6574d857fa09bef58f22e0e6d9cc7 Mon Sep 17 00:00:00 2001
From: Pelle Nilsson <pellenilsson@fastmail.fm>
Date: Thu, 10 May 2018 20:48:13 +0200
Subject: [PATCH] Make unsubscribe work with notifications for replies

---
 isso/db/comments.py       |  4 ++--
 isso/ext/notifications.py | 17 +++++++++++------
 isso/views/comments.py    | 22 +++++++++++++++-------
 3 files changed, 28 insertions(+), 15 deletions(-)

diff --git a/isso/db/comments.py b/isso/db/comments.py
index 0ba3964..d2181f3 100644
--- a/isso/db/comments.py
+++ b/isso/db/comments.py
@@ -81,14 +81,14 @@ class Comments:
             '    mode=1',
             'WHERE id=? AND mode=2'], (id, ))
 
-    def unsubscribe(self, id):
+    def unsubscribe(self, email, id):
         """
         Turn off email notifications for replies to this comment.
         """
         self.db.execute([
             'UPDATE comments SET',
             '    notification=0',
-            'WHERE id=?'], (id, ))
+            'WHERE email=? AND (id=? OR parent=?);'], (email, id, id))
 
     def update(self, id, data):
         """
diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py
index ccea39b..00f0a02 100644
--- a/isso/ext/notifications.py
+++ b/isso/ext/notifications.py
@@ -14,6 +14,11 @@ from email.utils import formatdate
 from email.header import Header
 from email.mime.text import MIMEText
 
+try:
+    from urllib.parse import quote
+except ImportError:
+    from urllib import quote
+
 import logging
 logger = logging.getLogger("isso")
 
@@ -99,7 +104,7 @@ class SMTP(object):
     def __iter__(self):
         yield "comments.new:after-save", self.notify
 
-    def format(self, thread, comment, comment_parent, admin=False):
+    def format(self, thread, comment, parent_comment, recipient=None, admin=False):
 
         rv = io.StringIO()
 
@@ -133,10 +138,10 @@ class SMTP(object):
                 rv.write("Activate comment: %s\n" % (uri + "/activate/" + key))
 
         else:
-            uri = self.general_host + "/id/%i" % comment_parent["id"]
-            key = self.isso.sign(('unsubscribe', comment_parent["id"]))
+            uri = self.general_host + "/id/%i" % parent_comment["id"]
+            key = self.isso.sign(('unsubscribe', recipient))
 
-            rv.write("Unsubscribe from this conversation: %s\n" % (uri + "/unsubscribe/" + key))
+            rv.write("Unsubscribe from this conversation: %s\n" % (uri + "/unsubscribe/" + quote(recipient) + "/" + key))
 
         rv.seek(0)
         return rv.read()
@@ -152,10 +157,10 @@ class SMTP(object):
                 email = comment_to_notify["email"]
                 if "email" in comment_to_notify and comment_to_notify["notification"] and email not in notified \
                     and comment_to_notify["id"] != comment["id"] and email != comment["email"]:
-                    body = self.format(thread, comment, comment_to_notify, admin=False)
+                    body = self.format(thread, comment, parent_comment, email, admin=False)
                     subject = "Re: New comment posted on %s" % thread["title"]
                     self.sendmail(subject, body, thread, comment, to=email)
-                    notified += email
+                    notified.append(email)
 
         body = self.format(thread, comment, None, admin=True)
         self.sendmail(thread["title"], body, thread, comment)
diff --git a/isso/views/comments.py b/isso/views/comments.py
index 4c12820..175c346 100644
--- a/isso/views/comments.py
+++ b/isso/views/comments.py
@@ -32,6 +32,10 @@ try:
     from urlparse import urlparse
 except ImportError:
     from urllib.parse import urlparse
+try:
+    from urllib import unquote
+except ImportError:
+    from urllib.parse import unquote
 try:
     from StringIO import StringIO
 except ImportError:
@@ -107,7 +111,7 @@ class API(object):
         ('view',        ('GET', '/id/<int:id>')),
         ('edit',        ('PUT', '/id/<int:id>')),
         ('delete',      ('DELETE', '/id/<int:id>')),
-        ('unsubscribe', ('GET', '/id/<int:id>/unsubscribe/<string:key>')),
+        ('unsubscribe', ('GET', '/id/<int:id>/unsubscribe/<string:email>/<string:key>')),
         ('moderate',    ('GET',  '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
         ('moderate',    ('POST', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
         ('like',        ('POST', '/id/<int:id>/like')),
@@ -494,18 +498,20 @@ class API(object):
         return resp
 
     """
-    @api {get} /id/:id/key unsubscribe
+    @api {get} /id/:id/:email/key unsubscribe
     @apiGroup Comment
     @apiDescription
         Opt out from getting any further email notifications about replies to a particular comment. In order to use this endpoint, the requestor needs a `key` that is usually obtained from an email sent out by isso.
 
     @apiParam {number} id
         The id of the comment to unsubscribe from replies to.
+    @apiParam {string} email
+        The email address of the subscriber.
     @apiParam {string} key
         The key to authenticate the subscriber.
 
-    @apiExample {curl} Unsubscribe from replies to comment with id 13:
-        curl -X GET 'https://comments.example.com/id/13/unsubscribe/TODO-COMPUTE-HASH'
+    @apiExample {curl} Unsubscribe Alice from replies to comment with id 13:
+        curl -X GET 'https://comments.example.com/id/13/unsubscribe/alice@example.com/TODO-COMPUTE-HASH'
 
     @apiSuccessExample {html} Using GET:
         &lt;!DOCTYPE html&gt;
@@ -523,13 +529,15 @@ class API(object):
         Yo
     """
 
-    def unsubscribe(self, environ, request, id, key):
+    def unsubscribe(self, environ, request, id, email, key):
+        email = unquote(email)
+
         try:
             rv = self.isso.unsign(key, max_age=2**32)
         except (BadSignature, SignatureExpired):
             raise Forbidden
 
-        if rv[0] != 'unsubscribe' or rv[1] != id:
+        if rv[0] != 'unsubscribe' or rv[1] != email:
             raise Forbidden
 
         item = self.comments.get(id)
@@ -538,7 +546,7 @@ class API(object):
             raise NotFound
 
         with self.isso.lock:
-            self.comments.unsubscribe(id)
+            self.comments.unsubscribe(email, id)
 
         modal = (
             "<!DOCTYPE html>"