From 3c20402b32f06a7a488e23df0b54811612d2d923 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 5 Sep 2014 19:57:33 -0400 Subject: [PATCH 1/4] Add a common base email template, translate the emails over to using jinja and add emails when e-mail addresses and passwords are changed. --- data/model/legacy.py | 4 +- emails/base.html | 46 ++++++++ emails/changeemail.html | 13 +++ emails/confirmemail.html | 13 +++ emails/emailchanged.html | 12 ++ emails/passwordchanged.html | 12 ++ emails/paymentfailure.html | 13 +++ emails/recovery.html | 18 +++ emails/repoauthorizeemail.html | 13 +++ endpoints/api/user.py | 3 +- endpoints/web.py | 6 +- util/useremails.py | 194 ++++++++++++++++++--------------- 12 files changed, 258 insertions(+), 89 deletions(-) create mode 100644 emails/base.html create mode 100644 emails/changeemail.html create mode 100644 emails/confirmemail.html create mode 100644 emails/emailchanged.html create mode 100644 emails/passwordchanged.html create mode 100644 emails/paymentfailure.html create mode 100644 emails/recovery.html create mode 100644 emails/repoauthorizeemail.html diff --git a/data/model/legacy.py b/data/model/legacy.py index 64bcdc860..bc49a585e 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -456,18 +456,20 @@ def confirm_user_email(code): user = code.user user.verified = True + old_email = None new_email = code.new_email if new_email: if find_user_by_email(new_email): raise DataModelException('E-mail address already used.') + old_email = user.email user.email = new_email user.save() code.delete_instance() - return user, new_email + return user, new_email, old_email def create_reset_password_email_code(email): diff --git a/emails/base.html b/emails/base.html new file mode 100644 index 000000000..286f83c98 --- /dev/null +++ b/emails/base.html @@ -0,0 +1,46 @@ + + + + + + {{ subject }} + + + + + + + +
+ +
+ + +
{{ app_title }}
+ +
+ + +
+ +
+ +
+ {% block content %}{% endblock %} +
+ +
+ + diff --git a/emails/changeemail.html b/emails/changeemail.html new file mode 100644 index 000000000..d247bdc2d --- /dev/null +++ b/emails/changeemail.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block content %} + +

E-mail Address Change Requested

+ +This email address was recently asked to become the new e-mail address for user {{ username | user_reference }}. +
+
+To confirm this email address, please click the following link:
+{{ app_link('confirm?code=' + token) }} + +{% endblock %} diff --git a/emails/confirmemail.html b/emails/confirmemail.html new file mode 100644 index 000000000..de94372cd --- /dev/null +++ b/emails/confirmemail.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block content %} + +

Please Confirm E-mail Address

+ +This email address was recently used to register user {{ username | user_reference }}. +
+
+To confirm this email address, please click the following link:
+{{ app_link('confirm?code=' + token) }} + +{% endblock %} diff --git a/emails/emailchanged.html b/emails/emailchanged.html new file mode 100644 index 000000000..ce6de5565 --- /dev/null +++ b/emails/emailchanged.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block content %} + +

Account E-mail Address Changed

+ +The email address for user {{ username | user_reference }} has been changed from this e-mail address to {{ new_email }}. +
+
+If this change was not expected, please immediately log into your {{ username | admin_reference }} and reset your email address. + +{% endblock %} diff --git a/emails/passwordchanged.html b/emails/passwordchanged.html new file mode 100644 index 000000000..e3fd554e4 --- /dev/null +++ b/emails/passwordchanged.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block content %} + +

Account Password Changed

+ +The password for user {{ username | user_reference }} has been updated. +
+
+If this change was not expected, please immediately log into your {{ username | admin_reference }} and reset your password. + +{% endblock %} diff --git a/emails/paymentfailure.html b/emails/paymentfailure.html new file mode 100644 index 000000000..790f590b4 --- /dev/null +++ b/emails/paymentfailure.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block content %} + +

Subscription Payment Failure

+ +Your recent payment for account {{ username | user_reference }} failed, which usually results in our payments processor canceling +your subscription automatically. If you would like to continue to use {{ app_title }} without interruption, +please add a new card to {{ app_title }} and re-subscribe to your plan.
+
+You can find the card and subscription management features under your {{ username | admin_reference }}
+ +{% endblock %} diff --git a/emails/recovery.html b/emails/recovery.html new file mode 100644 index 000000000..6f0267e39 --- /dev/null +++ b/emails/recovery.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block content %} + +

Account recovery

+ +A user at {{ app_link() }} has attempted to recover their account +using this email address. +
+
+If you made this request, please click the following link to recover your account and +change your password: +{{ app_link('recovery?code=' + token) }} +

+If you did not make this request, your account has not been compromised and the user was +not given access. Please disregard this email. + +{% endblock %} diff --git a/emails/repoauthorizeemail.html b/emails/repoauthorizeemail.html new file mode 100644 index 000000000..7ae33975c --- /dev/null +++ b/emails/repoauthorizeemail.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block content %} + +

Verify e-mail to receive repository notifications

+ +A request has been made to send notifications to this email address for repository {{ (namespace, repository) | repository_reference }} + +

+To verify this email address, please click the following link:
+{{ app_link('authrepoemail?code=' + token) }} + +{% endblock %} diff --git a/endpoints/api/user.py b/endpoints/api/user.py index ddf05aafa..4a5df20ee 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -19,7 +19,7 @@ from auth.permissions import (AdministerOrganizationPermission, CreateRepository from auth.auth_context import get_authenticated_user from auth import scopes from util.gravatar import compute_hash -from util.useremails import (send_confirmation_email, send_recovery_email, send_change_email) +from util.useremails import (send_confirmation_email, send_recovery_email, send_change_email, send_password_changed) import features @@ -165,6 +165,7 @@ class User(ApiResource): logger.debug('Changing password for user: %s', user.username) log_action('account_change_password', user.username) model.change_password(user, user_data['password']) + send_password_changed(user.username, user.email) if 'invoice_email' in user_data: logger.debug('Changing invoice_email for user: %s', user.username) diff --git a/endpoints/web.py b/endpoints/web.py index 19f9bb7f1..c538e703d 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -18,6 +18,7 @@ from endpoints.common import common_login, render_page_template, route_show_if, from endpoints.csrf import csrf_protect, generate_csrf_token from util.names import parse_repository_name from util.gravatar import compute_hash +from util.useremails import send_email_changed from auth import scopes import features @@ -241,10 +242,13 @@ def confirm_email(): new_email = None try: - user, new_email = model.confirm_user_email(code) + user, new_email, old_email = model.confirm_user_email(code) except model.DataModelException as ex: return render_page_template('confirmerror.html', error_message=ex.message) + if new_email: + send_email_changed(user.username, old_email, new_email) + common_login(user) return redirect(url_for('web.user', tab='email') diff --git a/util/useremails.py b/util/useremails.py index 0a78da7e3..f280e276b 100644 --- a/util/useremails.py +++ b/util/useremails.py @@ -1,116 +1,143 @@ from flask.ext.mail import Message from app import mail, app, get_app_url +from jinja2 import Template, Environment, FileSystemLoader, contextfilter +from data import model +from util.gravatar import compute_hash + +def user_reference(username): + user = model.get_user(username) + if not user: + return username + + return """ + + + %s + """ % (compute_hash(user.email), username) -CONFIRM_MESSAGE = """ -This email address was recently used to register the username '%s' -at Quay.io.
-
-To confirm this email address, please click the following link:
-%s/confirm?code=%s -""" +def repository_reference(pair): + (namespace, repository) = pair + + owner = model.get_user(namespace) + if not owner: + return "%s/%s" % (namespace, repository) + + return """ + + + %s/%s + + """ % (compute_hash(owner.email), get_app_url(), namespace, repository, namespace, repository) -CHANGE_MESSAGE = """ -This email address was recently asked to become the new e-mail address for username '%s' -at Quay.io.
-
-To confirm this email address, please click the following link:
-%s/confirm?code=%s -""" +def admin_reference(username): + user = model.get_user(username) + if not user: + return 'account settings' + + if user.organization: + return """ + organization's admin setting + """ % (get_app_url(), username) + else: + return """ + account settings + """ % (get_app_url()) -RECOVERY_MESSAGE = """ -A user at Quay.io has attempted to recover their account -using this email address.
-
-If you made this request, please click the following link to recover your account and -change your password: -%s/recovery?code=%s
-
-If you did not make this request, your account has not been compromised and the user was -not given access. Please disregard this email.
-""" +template_loader = FileSystemLoader(searchpath="emails") +template_env = Environment(loader=template_loader) +template_env.filters['user_reference'] = user_reference +template_env.filters['admin_reference'] = admin_reference +template_env.filters['repository_reference'] = repository_reference -SUBSCRIPTION_CHANGE = """ -Change: {0}
-Customer id: {1}
-Customer email: {2}
-Quay user or org name: {3}
-""" +def send_email(recipient, subject, template_file, parameters): + app_title = app.config['REGISTRY_TITLE_SHORT'] + app_url = get_app_url() + + def app_link_handler(url=None, title=None): + real_url = app_url + '/' + url if url else app_url + if not title: + title = real_url if url else app_title + + return '%s' % (real_url, title) + + parameters.update({ + 'subject': subject, + 'app_logo': 'https://quay.io/static/img/quay-logo.png', # TODO: make this pull from config + 'app_url': app_url, + 'app_title': app_title, + 'app_link': app_link_handler + }) + + rendered_html = template_env.get_template(template_file + '.html').render(parameters) + + msg = Message('[%s] %s' % (app_title, subject), sender='support@quay.io', recipients=[recipient]) + msg.html = rendered_html + mail.send(msg) -PAYMENT_FAILED = """ -Hi {0},
-
-Your recent payment for Quay.io failed, which usually results in our payments processorcanceling -your subscription automatically. If you would like to continue to use Quay.io without interruption, -please add a new card to Quay.io and re-subscribe to your plan.
-
-You can find the card and subscription management features under your account settings.
-
-Thanks and have a great day!
-
--Quay.io Support
-""" - - -AUTH_FORREPO_MESSAGE = """ -A request has been made to send notifications to this email address for the -Quay.io repository %s/%s. -
-To confirm this email address, please click the following link:
-%s/authrepoemail?code=%s -""" - - -SUBSCRIPTION_CHANGE_TITLE = 'Subscription Change - {0} {1}' +def send_password_changed(username, email): + send_email(email, 'Account password changed', 'passwordchanged', { + 'username': username + }) +def send_email_changed(username, old_email, new_email): + send_email(old_email, 'Account e-mail address changed', 'emailchanged', { + 'username': username, + 'new_email': new_email + }) def send_change_email(username, email, token): - msg = Message('Quay.io email change. Please confirm your email.', - sender='support@quay.io', # Why do I need this? - recipients=[email]) - msg.html = CHANGE_MESSAGE % (username, get_app_url(), get_app_url(), token, get_app_url(), token) - mail.send(msg) - + send_email(email, 'E-mail address change requested', 'changeemail', { + 'username': username, + 'token': token + }) def send_confirmation_email(username, email, token): - msg = Message('Welcome to Quay.io! Please confirm your email.', - sender='support@quay.io', # Why do I need this? - recipients=[email]) - msg.html = CONFIRM_MESSAGE % (username, get_app_url(), get_app_url(), token, get_app_url(), token) - mail.send(msg) - + send_email(email, 'Please confirm your e-mail address', 'confirmemail', { + 'username': username, + 'token': token + }) def send_repo_authorization_email(namespace, repository, email, token): - msg = Message('Quay.io Notification: Please confirm your email.', - sender='support@quay.io', # Why do I need this? - recipients=[email]) - msg.html = AUTH_FORREPO_MESSAGE % (get_app_url(), get_app_url(), namespace, repository, namespace, - repository, get_app_url(), token, get_app_url(), token) - mail.send(msg) - + subject = 'Please verify your e-mail address for repository %s/%s' % (namespace, repository) + send_email(email, subject, 'repoauthorizeemail', { + 'namespace': namespace, + 'repository': repository, + 'token': token + }) def send_recovery_email(email, token): - msg = Message('Quay.io account recovery.', - sender='support@quay.io', # Why do I need this? - recipients=[email]) - msg.html = RECOVERY_MESSAGE % (get_app_url(), get_app_url(), token, get_app_url(), token) - mail.send(msg) + subject = 'Account recovery' + send_email(email, subject, 'recovery', { + 'email': email, + 'token': token + }) + +def send_payment_failed(email, username): + send_email(email, 'Subscription Payment Failure', 'paymentfailure', { + 'username': username + }) def send_invoice_email(email, contents): + # Note: This completely generates the contents of the email, so we don't use the + # normal template here. msg = Message('Quay.io payment received - Thank you!', - sender='support@quay.io', # Why do I need this? + sender='support@quay.io', recipients=[email]) msg.html = contents mail.send(msg) +# INTERNAL EMAILS BELOW + def send_subscription_change(change_description, customer_id, customer_email, quay_username): + SUBSCRIPTION_CHANGE_TITLE = 'Subscription Change - {0} {1}' title = SUBSCRIPTION_CHANGE_TITLE.format(quay_username, change_description) msg = Message(title, sender='support@quay.io', recipients=['stripe@quay.io']) msg.html = SUBSCRIPTION_CHANGE.format(change_description, customer_id, customer_email, @@ -118,8 +145,3 @@ def send_subscription_change(change_description, customer_id, customer_email, qu mail.send(msg) -def send_payment_failed(customer_email, quay_username): - msg = Message('Quay.io Subscription Payment Failure', sender='support@quay.io', - recipients=[customer_email]) - msg.html = PAYMENT_FAILED.format(quay_username) - mail.send(msg) From e7606b13b6e914a9d48cbd617e1b598f940bb9fe Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 12 Sep 2014 17:10:23 -0400 Subject: [PATCH 2/4] Code review changes --- emails/base.html | 65 ++++++++++++++++++------------------- emails/passwordchanged.html | 3 +- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/emails/base.html b/emails/base.html index 286f83c98..33dac53de 100644 --- a/emails/base.html +++ b/emails/base.html @@ -6,41 +6,40 @@ {{ subject }} + @media only screen and (max-width: 600px) { + a[class="btn"] { + display: block !important; margin-bottom: 10px !important; background-image: none !important; margin-right: 0 !important; + } + div[class="column"] { + width: auto !important; float: none !important; + } + table.social div[class="column"] { + width: auto !important; + } + } + - - - - -
- -
- - -
{{ app_title }}
- -
- + +
+ + + + +
+ +
+ +
{{ app_title }}
+ +
+
-
- -
- {% block content %}{% endblock %} -
- +
+ - -
+ {% block content %}{% endblock %}
+
+ +
diff --git a/emails/passwordchanged.html b/emails/passwordchanged.html index e3fd554e4..07c6232cc 100644 --- a/emails/passwordchanged.html +++ b/emails/passwordchanged.html @@ -7,6 +7,7 @@ The password for user {{ username | user_reference }} has been updated.

-If this change was not expected, please immediately log into your {{ username | admin_reference }} and reset your password. +If this change was not expected, please immediately log into your account settings and reset your email address, +or contact support. {% endblock %} From e1f406f3c5df712f82394dfe068164350d615ccc Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 18 Sep 2014 13:05:08 -0400 Subject: [PATCH 3/4] Fix text of change email --- emails/changeemail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emails/changeemail.html b/emails/changeemail.html index d247bdc2d..ee9b909fc 100644 --- a/emails/changeemail.html +++ b/emails/changeemail.html @@ -7,7 +7,7 @@ This email address was recently asked to become the new e-mail address for user {{ username | user_reference }}.

-To confirm this email address, please click the following link:
+To confirm this change, please click the following link:
{{ app_link('confirm?code=' + token) }} {% endblock %} From b769926565a82c25ecb8213fb1d047e29549c5bf Mon Sep 17 00:00:00 2001 From: Jake Moshenko Date: Fri, 19 Sep 2014 10:03:35 -0400 Subject: [PATCH 4/4] Tweak the team invite email. Update the test database to reflect the latest structure. --- emails/teaminvite.html | 4 ++-- test/data/test.db | Bin 614400 -> 630784 bytes 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/emails/teaminvite.html b/emails/teaminvite.html index 948899d42..3d8ff9c14 100644 --- a/emails/teaminvite.html +++ b/emails/teaminvite.html @@ -2,9 +2,9 @@ {% block content %} -

Invitation to join team {{ teamname }}

+

Invitation to join team: {{ teamname }}

-{{ inviter | user_reference }} has invited you to join team {{ teamname }} under organization {{ organization | user_reference }}. +{{ inviter | user_reference }} has invited you to join the team {{ teamname }} under organization {{ organization | user_reference }}.

diff --git a/test/data/test.db b/test/data/test.db index 29f6e14441a0a4be638dec0fd273ff2ead6ba022..68f838e57c1414788549bfc39fec51ede0008c52 100644 GIT binary patch delta 10653 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