This repository has been archived by the owner on Feb 8, 2018. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 308
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Allow queuing emails without participant
- Loading branch information
1 parent
1fdffce
commit 89a3ed3
Showing
3 changed files
with
127 additions
and
26 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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. | ||
""" | ||
|
@@ -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 | ||
|
@@ -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() | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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") | ||
|