diff --git a/app/blueprints/admin/email.py b/app/blueprints/admin/email.py index e3c11ac..2861e48 100644 --- a/app/blueprints/admin/email.py +++ b/app/blueprints/admin/email.py @@ -55,7 +55,7 @@ def send_single_email(): text = form.text.data 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 render_template("admin/send_email.html", form=form, user=user) @@ -72,7 +72,7 @@ def send_bulk_email(): text = form.text.data html = render_markdown(text) 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")) diff --git a/app/blueprints/users/account.py b/app/blueprints/users/account.py index a1bb6cd..36827a4 100644 --- a/app/blueprints/users/account.py +++ b/app/blueprints/users/account.py @@ -23,7 +23,7 @@ from wtforms import * from wtforms.validators 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 passlib.pwd import genphrase @@ -106,7 +106,7 @@ def register(): if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data).first() 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)) else: 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) else: - send_anon_email.delay([email], "Unable to find account", """ + send_anon_email.delay(email, "Unable to find account", """

We were unable to perform the password reset as we could not find an account associated with this email. @@ -296,3 +296,51 @@ def verify_email(): return redirect(url_for("users.login")) else: 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() diff --git a/app/maillogger.py b/app/maillogger.py index 30b4f1f..23c4587 100644 --- a/app/maillogger.py +++ b/app/maillogger.py @@ -82,7 +82,8 @@ class FlaskMailHandler(logging.Handler): text = self.format(record) if self.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): diff --git a/app/models.py b/app/models.py index 288756c..2cdcb74 100644 --- a/app/models.py +++ b/app/models.py @@ -25,9 +25,11 @@ from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy, BaseQuery from sqlalchemy_searchable import SearchQueryMixin, make_searchable from sqlalchemy_utils.types import TSVectorType -from . import app, gravatar, login_manager + +from . import app, gravatar # Initialise database + db = SQLAlchemy(app) migrate = Migrate(app, db) make_searchable(db.metadata) @@ -276,6 +278,18 @@ class UserEmailVerification(db.Model): 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): # Package / release / etc PACKAGE_EDIT = 1 @@ -385,7 +399,7 @@ class Notification(db.Model): class UserNotificationPreferences(db.Model): 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") # 2 = immediate emails diff --git a/app/tasks/emails.py b/app/tasks/emails.py index 72a2aef..ff3cb93 100644 --- a/app/tasks/emails.py +++ b/app/tasks/emails.py @@ -18,14 +18,30 @@ from flask import render_template from flask_mail import Message from app import mail -from app.models import Notification, db +from app.models import Notification, db, EmailSubscription 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() -def sendVerifyEmail(newEmail, token): - msg = Message("Confirm email address", recipients=[newEmail]) +def sendVerifyEmail(email, token): + sub = get_email_subscription(email) + if sub.blacklisted: + return + + msg = Message("Confirm email address", recipients=[email]) msg.body = """ 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)) - msg.html = render_template("emails/verify.html", token=token) + msg.html = render_template("emails/verify.html", token=token, sub=sub) mail.send(msg) @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 - msg = Message(subject, recipients=to) + msg = Message(subject, recipients=[email]) msg.body = 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) @celery.task() -def send_user_email(to, subject, text, html=None): - return send_email_with_reason(to, subject, text, html, +def send_user_email(email: str, subject: str, text: str, html=None): + return send_email_with_reason(email, subject, text, html, "You are receiving this email because you are a registered user of ContentDB.") @celery.task() -def send_anon_email(to, subject, text, html=None): - return send_email_with_reason(to, subject, text, html, +def send_anon_email(email: str, subject: str, text: str, html=None): + 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.") def sendNotificationEmail(notification): + sub = get_email_subscription(notification.user.email) + if sub.blacklisted: + return + msg = Message(notification.title, recipients=[notification.user.email]) msg.body = """ @@ -74,10 +116,12 @@ def sendNotificationEmail(notification): View: {} Manage email settings: {} + Unsubscribe: {} """.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) diff --git a/app/templates/emails/base.html b/app/templates/emails/base.html index 48cf969..1ceb5a6 100644 --- a/app/templates/emails/base.html +++ b/app/templates/emails/base.html @@ -58,7 +58,10 @@

{% block footer %} - {{ reason }} + {{ reason }}
+ + {{ _("Unsubscribe") }} + {% endblock %}

diff --git a/app/templates/emails/notification.html b/app/templates/emails/notification.html index 0b2d59b..44fd228 100644 --- a/app/templates/emails/notification.html +++ b/app/templates/emails/notification.html @@ -24,8 +24,14 @@ {% endblock %} {% block footer %} - You are receiving this email because you are a registered user of ContentDB, and have email notifications enabled.
+ You are receiving this email because you are a registered user of ContentDB, + and have email notifications enabled.
+ {{ _("Manage your preferences") }} + | + + {{ _("Unsubscribe") }} + {% endblock %} diff --git a/app/templates/emails/verify.html b/app/templates/emails/verify.html index 84507b2..559d183 100644 --- a/app/templates/emails/verify.html +++ b/app/templates/emails/verify.html @@ -21,11 +21,14 @@

- Or paste this into your browser: {{ abs_url_for('users.verify_email', token=token) }} + Or paste this into your browser: {{ abs_url_for('users.verify_email', token=token) }}

{% endblock %} {% 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.
+ + {{ _("Unsubscribe") }} + {% endblock %} diff --git a/app/templates/emails/verify_unsubscribe.html b/app/templates/emails/verify_unsubscribe.html new file mode 100644 index 0000000..f9b3648 --- /dev/null +++ b/app/templates/emails/verify_unsubscribe.html @@ -0,0 +1,22 @@ +{% extends "emails/base.html" %} + +{% block content %} +

Hello!

+ +

+ We're sorry to see you go. You just need to do one more thing before your email is blacklisted. +

+ + + Unsubscribe + + +

+ Or paste this into your browser: {{ abs_url_for('users.unsubscribe', token=sub.token) }} +

+ +{% endblock %} + +{% block footer %} + You are receiving this email because someone (hopefully you) entered your email address in the unsubscribe form. +{% endblock %} diff --git a/app/templates/users/unsubscribe.html b/app/templates/users/unsubscribe.html new file mode 100644 index 0000000..ba1b86b --- /dev/null +++ b/app/templates/users/unsubscribe.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} + +{% block title %} + {{ _("Unsubscribe") }} +{% endblock %} + +{% block content %} +

{{ self.title() }}

+ +

+ {{ _("This will blacklist an email address, preventing ContentDB from ever sending emails to it - including password resets.") }} +

+ +{% if form %} + {% from "macros/forms.html" import render_field, render_submit_field %} +
+ {{ form.hidden_tag() }} + +

+ {{ _("Please enter the email address you wish to blacklist.") }} + {{ _("You will then need to confirm the email") }} +

+ + {{ render_field(form.email, tabindex=220) }} + + {{ render_submit_field(form.submit, tabindex=280) }} +
+{% else %} +
+ + +

+ {{ _("You may now unsubscribe.") }} +

+ + {% if user %} +
+

+ Unsubscribing may prevent you from being able to sign into the + account '{{ user.display_name }}'. +

+

+ ContentDB will no longer be able to send "forget password" and other essential system emails. + Consider editing your email notification preferences instead. +

+
+ {% else %} +

+ You won't be able to use this email with ContentDB anymore. +

+ {% endif %} + +
+ {% if user %} + + Edit Notification Preferences + + {% endif %} + + +
+
+{% endif %} + +{% endblock %} diff --git a/migrations/versions/06d23947e7ef_.py b/migrations/versions/06d23947e7ef_.py new file mode 100644 index 0000000..27b849b --- /dev/null +++ b/migrations/versions/06d23947e7ef_.py @@ -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 ###