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
c662b9d
commit 3d2430c
Showing
3 changed files
with
126 additions
and
21 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 |
---|---|---|
|
@@ -55,13 +55,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 | ||
|
@@ -73,19 +77,57 @@ 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_queue | ||
(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_queue ' | ||
'WHERE participant=%s 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_queue | ||
WHERE user_initiated | ||
AND participant=%s | ||
""", (participant.id, )) | ||
else: | ||
return cursor.one(""" | ||
SELECT COUNT(*) | ||
FROM email_queue | ||
WHERE user_initiated | ||
AND email_address=%s | ||
""", (email_address, )) | ||
|
||
|
||
def flush(self): | ||
"""Load messages queued for sending, and send them. | ||
""" | ||
|
@@ -142,22 +184,35 @@ 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: | ||
return None | ||
|
||
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: | ||
return None | ||
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 | ||
|
@@ -172,7 +227,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 |
---|---|---|
@@ -0,0 +1,10 @@ | ||
BEGIN; | ||
-- In some cases, we don't have a participant linked to emails | ||
ALTER TABLE email_queue 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_queue ADD COLUMN email_address text; | ||
|
||
ALTER TABLE email_queue 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 |
---|---|---|
|
@@ -7,22 +7,57 @@ | |
|
||
from gratipay.exceptions import 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_queue") | ||
|