Add unsubscribe

This commit is contained in:
rubenwardy 2020-12-05 20:36:09 +00:00
parent 5fe3b0b459
commit 085f0b49c6
11 changed files with 267 additions and 26 deletions

@ -55,7 +55,7 @@ def send_single_email():
text = form.text.data text = form.text.data
html = render_markdown(text) html = render_markdown(text)
task = send_user_email.delay([user.email], form.subject.data, text, html) task = send_user_email.delay(user.email, form.subject.data, text, html)
return redirect(url_for("tasks.check", id=task.id, r=next_url)) return redirect(url_for("tasks.check", id=task.id, r=next_url))
return render_template("admin/send_email.html", form=form, user=user) return render_template("admin/send_email.html", form=form, user=user)
@ -72,7 +72,7 @@ def send_bulk_email():
text = form.text.data text = form.text.data
html = render_markdown(text) html = render_markdown(text)
for user in User.query.filter(User.email != None).all(): for user in User.query.filter(User.email != None).all():
send_user_email.delay([user.email], form.subject.data, text, html) send_user_email.delay(user.email, form.subject.data, text, html)
return redirect(url_for("admin.admin_page")) return redirect(url_for("admin.admin_page"))

@ -23,7 +23,7 @@ from wtforms import *
from wtforms.validators import * from wtforms.validators import *
from app.models import * from app.models import *
from app.tasks.emails import sendVerifyEmail, send_anon_email from app.tasks.emails import sendVerifyEmail, send_anon_email, sendUnsubscribeVerifyEmail
from app.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog from app.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog
from passlib.pwd import genphrase from passlib.pwd import genphrase
@ -106,7 +106,7 @@ def register():
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first() user = User.query.filter_by(email=form.email.data).first()
if user: if user:
send_anon_email.delay([form.email.data], "Email already in use", send_anon_email.delay(form.email.data, "Email already in use",
"We were unable to create the account as the email is already in use by {}. Try a different email address.".format(user.display_name)) "We were unable to create the account as the email is already in use by {}. Try a different email address.".format(user.display_name))
else: else:
user = User(form.username.data, False, form.email.data, make_flask_login_password(form.password.data)) user = User(form.username.data, False, form.email.data, make_flask_login_password(form.password.data))
@ -159,7 +159,7 @@ def forgot_password():
sendVerifyEmail.delay(form.email.data, token) sendVerifyEmail.delay(form.email.data, token)
else: else:
send_anon_email.delay([email], "Unable to find account", """ send_anon_email.delay(email, "Unable to find account", """
<p> <p>
We were unable to perform the password reset as we could not find an account We were unable to perform the password reset as we could not find an account
associated with this email. associated with this email.
@ -296,3 +296,51 @@ def verify_email():
return redirect(url_for("users.login")) return redirect(url_for("users.login"))
else: else:
return redirect(url_for("homepage.home")) return redirect(url_for("homepage.home"))
class UnsubscribeForm(FlaskForm):
email = StringField("Email", [InputRequired(), Email()])
submit = SubmitField("Send")
def unsubscribe_verify():
form = UnsubscribeForm(request.form)
if form.validate_on_submit():
email = form.email.data
sub = EmailSubscription.query.filter_by(email=email).first()
if not sub:
sub = EmailSubscription(email)
db.session.add(sub)
sub.token = randomString(32)
db.session.commit()
sendUnsubscribeVerifyEmail.delay(form.email.data)
flash("Check your email address to continue the unsubscribe", "success")
return redirect(url_for("homepage.home"))
return render_template("users/unsubscribe.html", form=form)
def unsubscribe_manage(sub: EmailSubscription):
user = User.query.filter_by(email=sub.email).first()
if request.method == "POST":
sub.blacklisted = True
db.session.commit()
flash("That email is now blacklisted. Please contact an admin if you wish to undo this.", "success")
return redirect(url_for("homepage.home"))
return render_template("users/unsubscribe.html", user=user)
@bp.route("/unsubscribe/", methods=["GET", "POST"])
def unsubscribe():
token = request.args.get("token")
if token:
sub = EmailSubscription.query.filter_by(token=token).first()
if sub:
return unsubscribe_manage(sub)
return unsubscribe_verify()

@ -82,7 +82,8 @@ class FlaskMailHandler(logging.Handler):
text = self.format(record) if self.formatter else None text = self.format(record) if self.formatter else None
html = self.html_formatter.format(record) if self.html_formatter else None html = self.html_formatter.format(record) if self.html_formatter else None
send_user_email.delay(self.send_to, self.getSubject(record), text, html) for email in self.send_to:
send_user_email.delay(email, self.getSubject(record), text, html)
def register_mail_error_handler(app, mailer): def register_mail_error_handler(app, mailer):

@ -25,9 +25,11 @@ from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy, BaseQuery from flask_sqlalchemy import SQLAlchemy, BaseQuery
from sqlalchemy_searchable import SearchQueryMixin, make_searchable from sqlalchemy_searchable import SearchQueryMixin, make_searchable
from sqlalchemy_utils.types import TSVectorType from sqlalchemy_utils.types import TSVectorType
from . import app, gravatar, login_manager
from . import app, gravatar
# Initialise database # Initialise database
db = SQLAlchemy(app) db = SQLAlchemy(app)
migrate = Migrate(app, db) migrate = Migrate(app, db)
make_searchable(db.metadata) make_searchable(db.metadata)
@ -276,6 +278,18 @@ class UserEmailVerification(db.Model):
is_password_reset = db.Column(db.Boolean, nullable=False, default=False) is_password_reset = db.Column(db.Boolean, nullable=False, default=False)
class EmailSubscription(db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(100), nullable=False, unique=True)
blacklisted = db.Column(db.Boolean, nullable=False, default=False)
token = db.Column(db.String(32), nullable=True, default=None)
def __init__(self, email):
self.email = email
self.blacklisted = False
self.token = None
class NotificationType(enum.Enum): class NotificationType(enum.Enum):
# Package / release / etc # Package / release / etc
PACKAGE_EDIT = 1 PACKAGE_EDIT = 1
@ -385,7 +399,7 @@ class Notification(db.Model):
class UserNotificationPreferences(db.Model): class UserNotificationPreferences(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id')) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
user = db.relationship("User", back_populates="notification_preferences") user = db.relationship("User", back_populates="notification_preferences")
# 2 = immediate emails # 2 = immediate emails

@ -18,14 +18,30 @@
from flask import render_template from flask import render_template
from flask_mail import Message from flask_mail import Message
from app import mail from app import mail
from app.models import Notification, db from app.models import Notification, db, EmailSubscription
from app.tasks import celery from app.tasks import celery
from app.utils import abs_url_for, abs_url from app.utils import abs_url_for, abs_url, randomString
def get_email_subscription(email):
assert type(email) == str
ret = EmailSubscription.query.filter_by(email=email).first()
if not ret:
ret = EmailSubscription(email)
ret.token = randomString(32)
db.session.add(ret)
db.session.commit()
return ret
@celery.task() @celery.task()
def sendVerifyEmail(newEmail, token): def sendVerifyEmail(email, token):
msg = Message("Confirm email address", recipients=[newEmail]) sub = get_email_subscription(email)
if sub.blacklisted:
return
msg = Message("Confirm email address", recipients=[email])
msg.body = """ msg.body = """
This email has been sent to you because someone (hopefully you) This email has been sent to you because someone (hopefully you)
@ -38,34 +54,60 @@ def sendVerifyEmail(newEmail, token):
{} {}
""".format(abs_url_for('users.verify_email', token=token)) """.format(abs_url_for('users.verify_email', token=token))
msg.html = render_template("emails/verify.html", token=token) msg.html = render_template("emails/verify.html", token=token, sub=sub)
mail.send(msg) mail.send(msg)
@celery.task() @celery.task()
def send_email_with_reason(to, subject, text, html, reason): def sendUnsubscribeVerifyEmail(email):
sub = get_email_subscription(email)
if sub.blacklisted:
return
msg = Message("Confirm unsubscribe", recipients=[email])
msg.body = """
We're sorry to see you go. You just need to do one more thing before your email is blacklisted.
Click this link to blacklist email: {}
""".format(abs_url_for('users.unsubscribe', token=sub.token))
msg.html = render_template("emails/verify_unsubscribe.html", sub=sub)
mail.send(msg)
@celery.task()
def send_email_with_reason(email, subject, text, html, reason):
sub = get_email_subscription(email)
if sub.blacklisted:
return
from flask_mail import Message from flask_mail import Message
msg = Message(subject, recipients=to) msg = Message(subject, recipients=[email])
msg.body = text msg.body = text
html = html or text html = html or text
msg.html = render_template("emails/base.html", subject=subject, content=html, reason=reason) msg.html = render_template("emails/base.html", subject=subject, content=html, reason=reason, sub=sub)
mail.send(msg) mail.send(msg)
@celery.task() @celery.task()
def send_user_email(to, subject, text, html=None): def send_user_email(email: str, subject: str, text: str, html=None):
return send_email_with_reason(to, subject, text, html, return send_email_with_reason(email, subject, text, html,
"You are receiving this email because you are a registered user of ContentDB.") "You are receiving this email because you are a registered user of ContentDB.")
@celery.task() @celery.task()
def send_anon_email(to, subject, text, html=None): def send_anon_email(email: str, subject: str, text: str, html=None):
return send_email_with_reason(to, subject, text, html, return send_email_with_reason(email, subject, text, html,
"You are receiving this email because someone (hopefully you) entered your email address as a user's email.") "You are receiving this email because someone (hopefully you) entered your email address as a user's email.")
def sendNotificationEmail(notification): def sendNotificationEmail(notification):
sub = get_email_subscription(notification.user.email)
if sub.blacklisted:
return
msg = Message(notification.title, recipients=[notification.user.email]) msg = Message(notification.title, recipients=[notification.user.email])
msg.body = """ msg.body = """
@ -74,10 +116,12 @@ def sendNotificationEmail(notification):
View: {} View: {}
Manage email settings: {} Manage email settings: {}
Unsubscribe: {}
""".format(notification.title, abs_url(notification.url), """.format(notification.title, abs_url(notification.url),
abs_url_for("users.email_notifications", username=notification.user.username)) abs_url_for("users.email_notifications", username=notification.user.username),
abs_url_for("users.unsubscribe", token=sub.token))
msg.html = render_template("emails/notification.html", notification=notification) msg.html = render_template("emails/notification.html", notification=notification, sub=sub)
mail.send(msg) mail.send(msg)

@ -58,7 +58,10 @@
<div style="margin-top: 3em;font-size: 80%;color: #666;"> <div style="margin-top: 3em;font-size: 80%;color: #666;">
<p> <p>
{% block footer %} {% block footer %}
{{ reason }} {{ reason }} <br>
<a href="{{ abs_url_for('users.unsubscribe', token=sub.token) }}">
{{ _("Unsubscribe") }}
</a>
{% endblock %} {% endblock %}
</p> </p>
<p> <p>

@ -24,8 +24,14 @@
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}
You are receiving this email because you are a registered user of ContentDB, and have email notifications enabled.<br> You are receiving this email because you are a registered user of ContentDB,
and have email notifications enabled. <br>
<a href="{{ abs_url_for('users.email_notifications', username=notification.user.username) }}"> <a href="{{ abs_url_for('users.email_notifications', username=notification.user.username) }}">
{{ _("Manage your preferences") }} {{ _("Manage your preferences") }}
</a> </a>
|
<a href="{{ abs_url_for('users.unsubscribe', token=sub.token) }}">
{{ _("Unsubscribe") }}
</a>
{% endblock %} {% endblock %}

@ -21,11 +21,14 @@
</a> </a>
<p style="font-size: 80%;"> <p style="font-size: 80%;">
Or paste this into your browser: {{ abs_url_for('users.verify_email', token=token) }} Or paste this into your browser: <code>{{ abs_url_for('users.verify_email', token=token) }}</code>
<p> <p>
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}
You are receiving this email because someone (hopefully you) entered your email address as a user's email. You are receiving this email because someone (hopefully you) entered your email address as a user's email. <br>
<a href="{{ abs_url_for('users.unsubscribe', token=sub.token) }}">
{{ _("Unsubscribe") }}
</a>
{% endblock %} {% endblock %}

@ -0,0 +1,22 @@
{% extends "emails/base.html" %}
{% block content %}
<h2 style="margin-top: 0;">Hello!</h2>
<p>
We're sorry to see you go. You just need to do one more thing before your email is blacklisted.
</p>
<a class="btn" href="{{ abs_url_for('users.unsubscribe', token=sub.token) }}">
Unsubscribe
</a>
<p style="font-size: 80%;">
Or paste this into your browser: <code>{{ abs_url_for('users.unsubscribe', token=sub.token) }}</code>
<p>
{% endblock %}
{% block footer %}
You are receiving this email because someone (hopefully you) entered your email address in the unsubscribe form.
{% endblock %}

@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}
{{ _("Unsubscribe") }}
{% endblock %}
{% block content %}
<h1>{{ self.title() }}</h1>
<p>
{{ _("This will blacklist an email address, preventing ContentDB from ever sending emails to it - including password resets.") }}
</p>
{% if form %}
{% from "macros/forms.html" import render_field, render_submit_field %}
<form action="" method="POST" class="form" role="form">
{{ form.hidden_tag() }}
<p>
{{ _("Please enter the email address you wish to blacklist.") }}
{{ _("You will then need to confirm the email") }}
</p>
{{ render_field(form.email, tabindex=220) }}
{{ render_submit_field(form.submit, tabindex=280) }}
</form>
{% else %}
<form action="" method="POST" class="form" role="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<p>
{{ _("You may now unsubscribe.") }}
</p>
{% if user %}
<div class="alert alert-danger">
<p>
<strong>Unsubscribing may prevent you from being able to sign into the
account '{{ user.display_name }}'</strong>.
</p>
<p class="mb-0">
ContentDB will no longer be able to send "forget password" and other essential system emails.
Consider editing your email notification preferences instead.
</p>
</div>
{% else %}
<p class="alert alert-warning">
You won't be able to use this email with ContentDB anymore.
</p>
{% endif %}
<div class="button-group mt-4">
{% if user %}
<a class="btn btn-primary mr-3" href="{{ url_for('users.email_notifications', username=user.username) }}">
Edit Notification Preferences
</a>
{% endif %}
<input class="btn btn-danger" type="submit" value="Unsubscribe">
</div>
</form>
{% endif %}
{% endblock %}

@ -0,0 +1,35 @@
"""empty message
Revision ID: 06d23947e7ef
Revises: 5d7233cf8a00
Create Date: 2020-12-05 20:30:12.166357
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '06d23947e7ef'
down_revision = '5d7233cf8a00'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('email_subscription',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=100), nullable=False),
sa.Column('blacklisted', sa.Boolean(), nullable=False),
sa.Column('token', sa.String(length=32), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('email_subscription')
# ### end Alembic commands ###