Reply notification (#443)

Merging @pellenilsson reply notification PR

* Added reply notification for commenter

* Removed debug info

* Bugfix

* Add notification column to database if needed

* Make SMTP connections thread safe

* Include link to comment in email notifications

* Implement opt-out for email notifications

* Fix faulty check for parent comment

* Support notifications also for replies

* Don't send notification when someone responds to his/her own comment

* Make unsubscribe work with notifications for replies

* Correct hash in 'unsubscribe' API example

* Introduce public-endpoint setting

* Fix whitespace issue

* Postpone notifications to users until comment has been approved by moderator

* New setting general.reply-notifications

* Add client-side configuration setting reply-notifications

* Documentation for reply notifications
master
Benoît Latinier 6 years ago committed by GitHub
commit 53d5ad441c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -77,6 +77,11 @@ data-isso-require-email
Set to `true` when spam guard is configured with `require-email = true`.
data-isso-reply-notifications
-------------------
Set to `true` when reply notifications is configured with `reply-notifications = true`.
data-isso-max-comments-top and data-isso-max-comments-nested
------------------------------------------------------------

@ -88,6 +88,14 @@ notify
Send notifications via SMTP on new comments with activation (if
moderated) and deletion links.
reply-notifications
Allow users to request E-mail notifications for replies to their post.
It is highly recommended to also turn on moderation when enabling this
setting, as Isso can otherwise be easily exploited for sending spam.
Do not forget to configure the client accordingly.
log-file
Log console messages to file instead of standard out.
@ -156,6 +164,12 @@ listen
Does not apply for `uWSGI`.
public-endpoint
public URL that Isso is accessed from by end users. Should always be
a http:// or https:// absolute address. If left blank, automatic
detection is attempted. Normally only needs to be specified if
different than the `listen` setting.
reload
reload application, when the source code has changed. Useful for
development. Only works with the internal webserver.

@ -96,13 +96,16 @@ class Isso(object):
super(Isso, self).__init__(conf)
subscribers = []
smtp_backend = False
for backend in conf.getlist("general", "notify"):
if backend == "stdout":
subscribers.append(Stdout(None))
elif backend in ("smtp", "SMTP"):
subscribers.append(SMTP(self))
smtp_backend = True
else:
logger.warn("unknown notification backend '%s'", backend)
if smtp_backend or conf.getboolean("general", "reply-notifications"):
subscribers.append(SMTP(self))
self.signal = ext.Signal(*subscribers)

@ -239,6 +239,9 @@
#fff 10px,
#fff 20px
);
.isso-postbox > .form-wrapper > .notification-section {
display: none;
padding-bottom: 10px;
}
@media screen and (max-width:600px) {
.isso-postbox > .form-wrapper > .auth-section .input-wrapper {

@ -23,7 +23,7 @@ class Comments:
'mode', # status of the comment 1 = valid, 2 = pending,
# 4 = soft-deleted (cannot hard delete because of replies)
'remote_addr', 'text', 'author', 'email', 'website',
'likes', 'dislikes', 'voters']
'likes', 'dislikes', 'voters', 'notification']
def __init__(self, db):
@ -33,7 +33,12 @@ class Comments:
' tid REFERENCES threads(id), id INTEGER PRIMARY KEY, parent INTEGER,',
' created FLOAT NOT NULL, modified FLOAT, mode INTEGER, remote_addr VARCHAR,',
' text VARCHAR, author VARCHAR, email VARCHAR, website VARCHAR,',
' likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL);'])
' likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL,',
' notification INTEGER NOT NULL DEFAULT 0);'])
try:
self.db.execute(['ALTER TABLE comments ADD COLUMN notification INTEGER NOT NULL DEFAULT 0;'])
except:
pass
def add(self, uri, c):
"""
@ -50,16 +55,16 @@ class Comments:
'INSERT INTO comments (',
' tid, parent,'
' created, modified, mode, remote_addr,',
' text, author, email, website, voters )',
' text, author, email, website, voters, notification)',
'SELECT',
' threads.id, ?,',
' ?, ?, ?, ?,',
' ?, ?, ?, ?, ?',
' ?, ?, ?, ?, ?, ?',
'FROM threads WHERE threads.uri = ?;'], (
c.get('parent'),
c.get('created') or time.time(), None, c["mode"], c['remote_addr'],
c['text'], c.get('author'), c.get('email'), c.get('website'), buffer(
Bloomfilter(iterable=[c['remote_addr']]).array),
Bloomfilter(iterable=[c['remote_addr']]).array), c.get('notification'),
uri)
)
@ -76,6 +81,15 @@ class Comments:
' mode=1',
'WHERE id=? AND mode=2'], (id, ))
def unsubscribe(self, email, id):
"""
Turn off email notifications for replies to this comment.
"""
self.db.execute([
'UPDATE comments SET',
' notification=0',
'WHERE email=? AND (id=? OR parent=?);'], (email, id, id))
def update(self, id, data):
"""
Update comment :param:`id` with values from :param:`data` and return

@ -25,6 +25,9 @@ class Threads(object):
def __getitem__(self, uri):
return Thread(*self.db.execute("SELECT * FROM threads WHERE uri=?", (uri, )).fetchone())
def get(self, id):
return Thread(*self.db.execute("SELECT * FROM threads WHERE id=?", (id, )).fetchone())
def new(self, uri, title):
self.db.execute(
"INSERT INTO threads (uri, title) VALUES (?, ?)", (uri, title))

@ -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")
@ -31,37 +36,10 @@ else:
from _thread import start_new_thread
class SMTP(object):
def __init__(self, isso):
self.isso = isso
self.conf = isso.conf.section("smtp")
gh = isso.conf.get("general", "host")
if type(gh) == str:
self.general_host = gh
#if gh is not a string then gh is a list
else:
self.general_host = gh[0]
# test SMTP connectivity
try:
with self:
logger.info("connected to SMTP server")
except (socket.error, smtplib.SMTPException):
logger.exception("unable to connect to SMTP server")
class SMTPConnection(object):
if uwsgi:
def spooler(args):
try:
self._sendmail(args[b"subject"].decode("utf-8"),
args["body"].decode("utf-8"))
except smtplib.SMTPConnectError:
return uwsgi.SPOOL_RETRY
else:
return uwsgi.SPOOL_OK
uwsgi.spooler = spooler
def __init__(self, conf):
self.conf = conf
def __enter__(self):
klass = (smtplib.SMTP_SSL if self.conf.get(
@ -91,10 +69,40 @@ class SMTP(object):
def __exit__(self, exc_type, exc_value, traceback):
self.client.quit()
class SMTP(object):
def __init__(self, isso):
self.isso = isso
self.conf = isso.conf.section("smtp")
self.public_endpoint = isso.conf.get("server", "public-endpoint") or local("host")
self.admin_notify = any((n in ("smtp", "SMTP")) for n in isso.conf.getlist("general", "notify"))
self.reply_notify = isso.conf.getboolean("general", "reply-notifications")
# test SMTP connectivity
try:
with SMTPConnection(self.conf):
logger.info("connected to SMTP server")
except (socket.error, smtplib.SMTPException):
logger.exception("unable to connect to SMTP server")
if uwsgi:
def spooler(args):
try:
self._sendmail(args[b"subject"].decode("utf-8"),
args["body"].decode("utf-8"))
except smtplib.SMTPConnectError:
return uwsgi.SPOOL_RETRY
else:
return uwsgi.SPOOL_OK
uwsgi.spooler = spooler
def __iter__(self):
yield "comments.new:after-save", self.notify
yield "comments.new:after-save", self.notify_new
yield "comments.activate", self.notify_activated
def format(self, thread, comment):
def format(self, thread, comment, parent_comment, recipient=None, admin=False):
rv = io.StringIO()
@ -107,40 +115,74 @@ class SMTP(object):
rv.write(comment["text"] + "\n")
rv.write("\n")
if comment["website"]:
rv.write("User's URL: %s\n" % comment["website"])
if admin:
if comment["website"]:
rv.write("User's URL: %s\n" % comment["website"])
rv.write("IP address: %s\n" % comment["remote_addr"])
rv.write("IP address: %s\n" % comment["remote_addr"])
rv.write("Link to comment: %s\n" %
(local("origin") + thread["uri"] + "#isso-%i" % comment["id"]))
rv.write("\n")
rv.write("---\n")
uri = self.general_host + "/id/%i" % comment["id"]
key = self.isso.sign(comment["id"])
if admin:
uri = self.public_endpoint + "/id/%i" % comment["id"]
key = self.isso.sign(comment["id"])
rv.write("---\n")
rv.write("Delete comment: %s\n" % (uri + "/delete/" + key))
rv.write("Delete comment: %s\n" % (uri + "/delete/" + key))
if comment["mode"] == 2:
rv.write("Activate comment: %s\n" % (uri + "/activate/" + key))
if comment["mode"] == 2:
rv.write("Activate comment: %s\n" % (uri + "/activate/" + key))
rv.seek(0)
return rv.read()
else:
uri = self.public_endpoint + "/id/%i" % parent_comment["id"]
key = self.isso.sign(('unsubscribe', recipient))
def notify(self, thread, comment):
rv.write("Unsubscribe from this conversation: %s\n" % (uri + "/unsubscribe/" + quote(recipient) + "/" + key))
body = self.format(thread, comment)
rv.seek(0)
return rv.read()
def notify_new(self, thread, comment):
if self.admin_notify:
body = self.format(thread, comment, None, admin=True)
self.sendmail(thread["title"], body, thread, comment)
if comment["mode"] == 1:
self.notify_users(thread, comment)
def notify_activated(self, thread, comment):
self.notify_users(thread, comment)
def notify_users(self, thread, comment):
if self.reply_notify and "parent" in comment and comment["parent"] is not None:
# Notify interested authors that a new comment is posted
notified = []
parent_comment = self.isso.db.comments.get(comment["parent"])
comments_to_notify = [parent_comment] if parent_comment is not None else []
comments_to_notify += self.isso.db.comments.fetch(thread["uri"], mode=1, parent=comment["parent"])
for comment_to_notify in comments_to_notify:
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, parent_comment, email, admin=False)
subject = "Re: New comment posted on %s" % thread["title"]
self.sendmail(subject, body, thread, comment, to=email)
notified.append(email)
def sendmail(self, subject, body, thread, comment, to=None):
if uwsgi:
uwsgi.spool({b"subject": thread["title"].encode("utf-8"),
b"body": body.encode("utf-8")})
uwsgi.spool({b"subject": subject.encode("utf-8"),
b"body": body.encode("utf-8"),
b"to": to})
else:
start_new_thread(self._retry, (thread["title"], body))
start_new_thread(self._retry, (subject, body, to))
def _sendmail(self, subject, body):
def _sendmail(self, subject, body, to=None):
from_addr = self.conf.get("from")
to_addr = self.conf.get("to")
to_addr = to or self.conf.get("to")
msg = MIMEText(body, 'plain', 'utf-8')
msg['From'] = from_addr
@ -148,13 +190,13 @@ class SMTP(object):
msg['Date'] = formatdate(localtime=True)
msg['Subject'] = Header(subject, 'utf-8')
with self as con:
with SMTPConnection(self.conf) as con:
con.sendmail(from_addr, to_addr, msg.as_string())
def _retry(self, subject, body):
def _retry(self, subject, body, to):
for x in range(5):
try:
self._sendmail(subject, body)
self._sendmail(subject, body, to)
except smtplib.SMTPConnectError:
time.sleep(60)
else:
@ -187,5 +229,5 @@ class Stdout(object):
def _delete_comment(self, id):
logger.info('comment %i deleted', id)
def _activate_comment(self, id):
logger.info("comment %s activated" % id)
def _activate_comment(self, thread, comment):
logger.info("comment %(id)s activated" % thread)

@ -7,6 +7,7 @@ define(function() {
"reply-to-self": false,
"require-email": false,
"require-author": false,
"reply-notifications": false,
"max-comments-top": "inf",
"max-comments-nested": 5,
"reveal-on-click": 5,

@ -90,6 +90,8 @@ define(function() {
this.focus = function() { node.focus() };
this.scrollIntoView = function(args) { node.scrollIntoView(args) };
this.checked = function() { return node.checked; };
this.setAttribute = function(key, value) { node.setAttribute(key, value) };
this.getAttribute = function(key) { return node.getAttribute(key) };

@ -6,6 +6,7 @@ define({
"postbox-preview": "Preview",
"postbox-edit": "Edit",
"postbox-submit": "Submit",
"postbox-notification": "Subscribe to email notification of replies",
"num-comments": "One Comment\n{{ n }} Comments",
"no-comments": "No Comments Yet",

@ -6,6 +6,7 @@ define({
"postbox-preview": "Aperçu",
"postbox-edit": "Éditer",
"postbox-submit": "Soumettre",
"postbox-notification": "S'abonner aux notifications de réponses",
"num-comments": "{{ n }} commentaire\n{{ n }} commentaires",
"no-comments": "Aucun commentaire pour l'instant",
"atom-feed": "Flux Atom",

@ -40,6 +40,17 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
return true;
};
// only display notification checkbox if email is filled in
var email_edit = function() {
if (config["reply-notifications"] && $("[name='email']", el).value.length > 0) {
$(".notification-section", el).show();
} else {
$(".notification-section", el).hide();
}
};
$("[name='email']", el).on("input", email_edit);
email_edit();
// email is not optional if this config parameter is set
if (config["require-email"]) {
$("[name='email']", el).setAttribute("placeholder",
@ -89,7 +100,8 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
author: author, email: email, website: website,
text: utils.text($(".textarea", el).innerHTML),
parent: parent || null,
title: $("#isso-thread").getAttribute("data-title") || null
title: $("#isso-thread").getAttribute("data-title") || null,
notification: $("[name=notification]", el).checked() ? 1 : 0,
}).then(function(comment) {
$(".textarea", el).innerHTML = "";
$(".textarea", el).blur();

@ -25,3 +25,7 @@ div(class='isso-postbox')
p(class='post-action')
input(type='button' name='edit'
value=i18n('postbox-edit'))
section(class='notification-section')
label
input(type='checkbox' name='notification')
= i18n('postbox-notification')

@ -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:
@ -93,28 +97,29 @@ def xhr(func):
class API(object):
FIELDS = set(['id', 'parent', 'text', 'author', 'website',
'mode', 'created', 'modified', 'likes', 'dislikes', 'hash', 'gravatar_image'])
'mode', 'created', 'modified', 'likes', 'dislikes', 'hash', 'gravatar_image', 'notification'])
# comment fields, that can be submitted
ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title'])
ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title', 'notification'])
VIEWS = [
('fetch', ('GET', '/')),
('new', ('POST', '/new')),
('count', ('GET', '/count')),
('counts', ('POST', '/count')),
('feed', ('GET', '/feed')),
('view', ('GET', '/id/<int:id>')),
('edit', ('PUT', '/id/<int:id>')),
('delete', ('DELETE', '/id/<int:id>')),
('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')),
('dislike', ('POST', '/id/<int:id>/dislike')),
('demo', ('GET', '/demo')),
('preview', ('POST', '/preview')),
('login', ('POST', '/login')),
('admin', ('GET', '/admin'))
('fetch', ('GET', '/')),
('new', ('POST', '/new')),
('count', ('GET', '/count')),
('counts', ('POST', '/count')),
('feed', ('GET', '/feed')),
('view', ('GET', '/id/<int:id>')),
('edit', ('PUT', '/id/<int:id>')),
('delete', ('DELETE', '/id/<int:id>')),
('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')),
('dislike', ('POST', '/id/<int:id>/dislike')),
('demo', ('GET', '/demo')),
('preview', ('POST', '/preview')),
('login', ('POST', '/login')),
('admin', ('GET', '/admin'))
]
def __init__(self, isso, hasher):
@ -492,6 +497,70 @@ class API(object):
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % id))
return resp
"""
@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 Alice from replies to comment with id 13:
curl -X GET 'https://comments.example.com/id/13/unsubscribe/alice@example.com/WyJ1bnN1YnNjcmliZSIsImFsaWNlQGV4YW1wbGUuY29tIl0.DdcH9w.Wxou-l22ySLFkKUs7RUHnoM8Kos'
@apiSuccessExample {html} Using GET:
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;script&gt;
if (confirm('Delete: Are you sure?')) {
xhr = new XMLHttpRequest;
xhr.open('POST', window.location.href);
xhr.send(null);
}
&lt;/script&gt;
@apiSuccessExample Using POST:
Yo
"""
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] != email:
raise Forbidden
item = self.comments.get(id)
if item is None:
raise NotFound
with self.isso.lock:
self.comments.unsubscribe(email, id)
modal = (
"<!DOCTYPE html>"
"<html>"
"<head>"
" <title>Successfully unsubscribed</title>"
"</head>"
"<body>"
" <p>You have been unsubscribed from replies in the given conversation.</p>"
"</body>"
"</html>")
return Response(modal, 200, content_type="text/html")
"""
@api {post} /id/:id/:action/key moderate
@apiGroup Comment
@ -554,9 +623,12 @@ class API(object):
return Response(modal, 200, content_type="text/html")
if action == "activate":
if item['mode'] == 1:
return Response("Already activated", 200)
with self.isso.lock:
self.comments.activate(id)
self.signal("comments.activate", id)
thread = self.threads.get(item['tid'])
self.signal("comments.activate", thread, item)
return Response("Yo", 200)
elif action == "edit":
data = request.get_json()

@ -9,6 +9,7 @@ dbpath = /var/isso/comments.db
host = http://isso-dev.local/
max-age = 15m
notify = stdout
reply-notifications = false
log-file = /var/log/isso.log
admin_password = strong_default_password_for_isso_admin

@ -43,6 +43,11 @@ max-age = 15m
# moderated) and deletion links.
notify = stdout
# Allow users to request E-mail notifications for replies to their post.
# WARNING: It is highly recommended to also turn on moderation when enabling
# this setting, as Isso can otherwise be easily exploited for sending spam.
reply-notifications=false
# Log console messages to file instead of standard output.
log-file =
@ -78,6 +83,11 @@ purge-after = 30d
# for details). Does not apply for uWSGI.
listen = http://localhost:8080
# public URL that Isso is accessed from by end users. Should always be a
# http:// or https:// absolute address. If left blank, automatic detection is
# attempted.
public-endpoint =
# reload application, when the source code has changed. Useful for development.
# Only works with the internal webserver.
reload = off

Loading…
Cancel
Save