Skip to content
This repository has been archived by the owner on Feb 8, 2018. It is now read-only.

Support queueing emails without participants #4548

Merged
merged 2 commits into from
Sep 8, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions deploy/before.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
BEGIN;
-- In some cases, we don't have a participant linked to emails
ALTER TABLE email_messages ALTER COLUMN participant DROP NOT NULL;

-- Email address to send emails to. If not provided, participant's primary email will be used.
ALTER TABLE email_messages ADD COLUMN email_address text;

ALTER TABLE email_messages ADD CONSTRAINT email_or_participant_required
CHECK ((participant IS NOT NULL) OR (email_address IS NOT NULL));
END;
21 changes: 0 additions & 21 deletions emails/initial.spt

This file was deleted.

98 changes: 77 additions & 21 deletions gratipay/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,17 @@ def _have_ses(self, env):
and env.aws_ses_default_region


def put(self, to, template, _user_initiated=True, **context):
def put(self, to, template, _user_initiated=True, email=None, **context):
"""Put an email message on the queue.

:param Participant to: the participant to send the email message to
:param Participant to: the participant to send the email message to.
In cases where an email is not linked to a participant, this can be
``None``.
:param unicode template: the name of the template to use when rendering
the email, corresponding to a filename in ``emails/`` without the
file extension
:param unicode email: The email address to send this message to. If not
provided, the ``to`` participant's primary email is used.
:param bool _user_initiated: user-initiated emails are throttled;
system-initiated messages don't count against throttling
:param dict context: the values to use when rendering the template
Expand All @@ -74,24 +78,59 @@ def put(self, to, template, _user_initiated=True, **context):
:returns: ``None``

"""

assert to or email # Either participant or email address required.

with self.db.get_cursor() as cursor:
participant_id = to.id if to else None
cursor.run("""
INSERT INTO email_messages
(participant, spt_name, context, user_initiated)
VALUES (%s, %s, %s, %s)
""", (to.id, template, pickle.dumps(context), _user_initiated))
(participant,
email_address,
spt_name,
context,
user_initiated)
VALUES (%s, %s, %s, %s, %s)
""", (participant_id, email, template, pickle.dumps(context), _user_initiated))

if _user_initiated:
n = cursor.one("""
SELECT count(*)
FROM email_messages
WHERE participant=%s
AND result is null
AND user_initiated
""", (to.id,))
if n > self.allow_up_to:
nqueued = self._get_nqueued(cursor, to, email)
if nqueued > self.allow_up_to:
raise Throttled()


def _get_nqueued(self, cursor, participant, email_address):
"""Returns the number of messages already queued for the given
participant or email address. Prefers participant if provided, falls
back to email_address otherwise.

:param Participant participant: The participant to check queued messages
for.

:param unicode email_address: The email address to check queued messages
for.

:returns number of queued messages
"""

if participant:
return cursor.one("""
SELECT COUNT(*)
FROM email_messages
WHERE user_initiated
AND participant=%s
AND result is null
""", (participant.id, ))
else:
return cursor.one("""
SELECT COUNT(*)
FROM email_messages
WHERE user_initiated
AND email_address=%s
AND result is null
""", (email_address, ))


def flush(self):
"""Load messages queued for sending, and send them.
"""
Expand Down Expand Up @@ -140,22 +179,34 @@ def _prepare_email_message_for_ses(self, rec):
#. ``participant.email_address``.

"""
to = Participant.from_id(rec.participant)
participant = Participant.from_id(rec.participant)
spt = self._email_templates[rec.spt_name]
context = pickle.loads(rec.context)

context['participant'] = to
context['username'] = to.username
email = rec.email_address or participant.email_address

# Previously, email_address was stored in the 'email' key on `context`
# and not in the `email_address` field. Let's handle that case so that
# old emails don't suffer
#
# TODO: Remove this once we're sure old emails have gone out.
email = context.get('email', email)

if not email:
raise NoEmailAddress()

context['email'] = email
if participant:
context['participant'] = participant
context['username'] = participant.username
context['button_style'] = (
"color: #fff; text-decoration:none; display:inline-block; "
"padding: 0 15px; background: #396; white-space: nowrap; "
"font: normal 14px/40px Arial, sans-serif; border-radius: 3px"
)
context.setdefault('include_unsubscribe', True)
email = context.setdefault('email', to.email_address)
if not email:
raise NoEmailAddress()
langs = i18n.parse_accept_lang(to.email_lang or 'en')
accept_lang = (participant and participant.email_lang) or 'en'
langs = i18n.parse_accept_lang(accept_lang)
locale = i18n.match_lang(langs)
i18n.add_helpers_to_context(self.tell_sentry, context, locale)
context['escape'] = lambda s: s
Expand All @@ -170,7 +221,12 @@ def render(t, context):
message = {}
message['Source'] = 'Gratipay Support <[email protected]>'
message['Destination'] = {}
message['Destination']['ToAddresses'] = ["%s <%s>" % (to.username, email)] # "Name <[email protected]>"
if participant:
# "username <[email protected]>"
destination = "%s <%s>" % (participant.username, email)
else:
destination = email
message['Destination']['ToAddresses'] = [destination]
message['Message'] = {}
message['Message']['Subject'] = {}
message['Message']['Subject']['Data'] = spt['subject'].render(context).strip()
Expand Down
45 changes: 40 additions & 5 deletions tests/py/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,57 @@

from gratipay.exceptions import NoEmailAddress, Throttled
from gratipay.testing import Harness
from gratipay.testing.email import SentEmailHarness
from gratipay.testing.email import SentEmailHarness, QueuedEmailHarness


class TestPut(SentEmailHarness):
class TestPut(QueuedEmailHarness):

def setUp(self):
SentEmailHarness.setUp(self)
QueuedEmailHarness.setUp(self)
self.alice = self.make_participant('alice', claimed_time='now', email_address='[email protected]')

def test_queueing_email_is_throttled(self):
def test_put_with_only_email(self):
self.app.email_queue.put(None, 'base', email='[email protected]')
last_email = self.get_last_email()
assert last_email['to'] == '[email protected]'

def test_put_with_only_participant(self):
self.app.email_queue.put(self.alice, 'base')

# Should pickup primary email
assert self.get_last_email()['to'] == 'alice <[email protected]>'

def test_put_with_participant_and_email(self):
self.app.email_queue.put(self.alice, 'base', email='[email protected]')

# Should prefer given email over primary
assert self.get_last_email()['to'] == 'alice <[email protected]>'

def test_put_complains_if_participant_and_email_not_provided(self):
with raises(AssertionError):
self.app.email_queue.put(None, "base")

def test_put_throttles_on_participant(self):
self.app.email_queue.put(self.alice, "base")
self.app.email_queue.put(self.alice, "base")
self.app.email_queue.put(self.alice, "base")
raises(Throttled, self.app.email_queue.put, self.alice, "base")

def test_queueing_email_writes_timestamp(self):
def test_put_throttles_on_email(self):
self.app.email_queue.put(None, "base", email="[email protected]")
self.app.email_queue.put(None, "base", email="[email protected]")
self.app.email_queue.put(None, "base", email="[email protected]")
with raises(Throttled):
self.app.email_queue.put(None, "base", email="[email protected]")

def test_put_throttles_on_participant_across_different_emails(self):
self.app.email_queue.put(self.alice, "base", email="[email protected]")
self.app.email_queue.put(self.alice, "base", email="[email protected]")
self.app.email_queue.put(self.alice, "base", email="[email protected]")
with raises(Throttled):
self.app.email_queue.put(self.alice, "base", email="[email protected]")

def test_timestamp_is_written(self):
self.app.email_queue.put(self.alice, "base")

ctime = self.db.one("SELECT EXTRACT(epoch FROM ctime) FROM email_messages")
Expand Down