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

Commit

Permalink
Allow queuing emails without participant
Browse files Browse the repository at this point in the history
  • Loading branch information
rohitpaulk authored and chadwhitacre committed Sep 1, 2017
1 parent 1fdffce commit 89a3ed3
Show file tree
Hide file tree
Showing 3 changed files with 127 additions and 26 deletions.
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;
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

0 comments on commit 89a3ed3

Please sign in to comment.