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

Commit

Permalink
Factor out an email mixin
Browse files Browse the repository at this point in the history
  • Loading branch information
chadwhitacre committed Feb 20, 2017
1 parent 9ea1b0c commit 53a7f29
Show file tree
Hide file tree
Showing 2 changed files with 246 additions and 239 deletions.
241 changes: 2 additions & 239 deletions gratipay/models/participant/__init__.py
Expand Up @@ -2,17 +2,13 @@
"""
from __future__ import print_function, unicode_literals

from datetime import timedelta
from decimal import Decimal
import pickle
from time import sleep
import uuid

from aspen.utils import utcnow
import balanced
import braintree
from dependency_injection import resolve_dependencies
from markupsafe import escape as htmlescape
from postgres.orm import Model
from psycopg2 import IntegrityError

Expand All @@ -25,11 +21,6 @@
UsernameIsRestricted,
UsernameAlreadyTaken,
BadAmount,
EmailAlreadyTaken,
CannotRemovePrimaryEmail,
EmailNotVerified,
TooManyEmailAddresses,
ResendingTooFast,
)

from gratipay.billing.instruments import CreditCard
Expand All @@ -38,32 +29,28 @@
from gratipay.models.exchange_route import ExchangeRoute
from gratipay.models.team import Team
from gratipay.models.team.takes import ZERO
from gratipay.security.crypto import constant_time_compare
from gratipay.utils import (
i18n,
is_card_expiring,
emails,
markdown,
notifications,
pricing,
encode_for_querystring,
)
from gratipay.utils.username import safely_reserve_a_username

from .identity import Identity
from .email import Email

ASCII_ALLOWED_IN_USERNAME = set("0123456789"
"abcdefghijklmnopqrstuvwxyz"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
".,-_:@ ")
# We use | in Sentry logging, so don't make that allowable. :-)

EMAIL_HASH_TIMEOUT = timedelta(hours=24)

USERNAME_MAX_SIZE = 32


class Participant(Model, Identity):
class Participant(Model, Email, Identity):
"""Represent a Gratipay participant.
"""

Expand Down Expand Up @@ -378,230 +365,6 @@ def clear_personal_information(self, cursor):
self.set_attributes(**r._asdict())


# Emails
# ======

def add_email(self, email, resend_threshold='3 minutes'):
"""
This is called when
1) Adding a new email address
2) Resending the verification email for an unverified email address
Returns the number of emails sent.
"""

# Check that this address isn't already verified
owner = self.db.one("""
SELECT p.username
FROM emails e INNER JOIN participants p
ON e.participant_id = p.id
WHERE e.address = %(email)s
AND e.verified IS true
""", locals())
if owner:
if owner == self.username:
return 0
else:
raise EmailAlreadyTaken(email)

if len(self.get_emails()) > 9:
raise TooManyEmailAddresses(email)

nonce = str(uuid.uuid4())
verification_start = utcnow()

nrecent = self.db.one( "SELECT count(*) FROM emails WHERE address=%s AND "
"%s - verification_start < %s"
, (email, verification_start, resend_threshold)
)
if nrecent:
raise ResendingTooFast()

try:
with self.db.get_cursor() as c:
add_event(c, 'participant', dict(id=self.id, action='add', values=dict(email=email)))
c.run("""
INSERT INTO emails
(address, nonce, verification_start, participant_id)
VALUES (%s, %s, %s, %s)
""", (email, nonce, verification_start, self.id))
except IntegrityError:
nonce = self.db.one("""
UPDATE emails
SET verification_start=%s
WHERE participant_id=%s
AND address=%s
AND verified IS NULL
RETURNING nonce
""", (verification_start, self.id, email))
if not nonce:
return self.add_email(email)

base_url = gratipay.base_url
username = self.username_lower
encoded_email = encode_for_querystring(email)
link = "{base_url}/~{username}/emails/verify.html?email2={encoded_email}&nonce={nonce}"
r = self.send_email('verification',
email=email,
link=link.format(**locals()),
include_unsubscribe=False)
assert r == 1 # Make sure the verification email was sent
if self.email_address:
self.send_email('verification_notice',
new_email=email,
include_unsubscribe=False)
return 2
return 1

def update_email(self, email):
if not getattr(self.get_email(email), 'verified', False):
raise EmailNotVerified(email)
username = self.username
with self.db.get_cursor() as c:
add_event(c, 'participant', dict(id=self.id, action='set', values=dict(primary_email=email)))
c.run("""
UPDATE participants
SET email_address=%(email)s
WHERE username=%(username)s
""", locals())
self.set_attributes(email_address=email)

def verify_email(self, email, nonce):
if '' in (email, nonce):
return emails.VERIFICATION_MISSING
r = self.get_email(email)
if r is None:
return emails.VERIFICATION_FAILED
if r.verified:
assert r.nonce is None # and therefore, order of conditions matters
return emails.VERIFICATION_REDUNDANT
if not constant_time_compare(r.nonce, nonce):
return emails.VERIFICATION_FAILED
if (utcnow() - r.verification_start) > EMAIL_HASH_TIMEOUT:
return emails.VERIFICATION_EXPIRED
try:
self.db.run("""
UPDATE emails
SET verified=true, verification_end=now(), nonce=NULL
WHERE participant_id=%s
AND address=%s
AND verified IS NULL
""", (self.id, email))
except IntegrityError:
return emails.VERIFICATION_STYMIED

if not self.email_address:
self.update_email(email)
return emails.VERIFICATION_SUCCEEDED

def get_email(self, email):
return self.db.one("""
SELECT *
FROM emails
WHERE participant_id=%s
AND address=%s
""", (self.id, email))

def get_emails(self):
return self.db.all("""
SELECT *
FROM emails
WHERE participant_id=%s
ORDER BY id
""", (self.id,))

def get_verified_email_addresses(self):
return [email.address for email in self.get_emails() if email.verified]

def remove_email(self, address):
if address == self.email_address:
raise CannotRemovePrimaryEmail()
with self.db.get_cursor() as c:
add_event(c, 'participant', dict(id=self.id, action='remove', values=dict(email=address)))
c.run("DELETE FROM emails WHERE participant_id=%s AND address=%s",
(self.id, address))

def send_email(self, spt_name, **context):
context['participant'] = self
context['username'] = self.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', self.email_address)
if not email:
return 0 # Not Sent
langs = i18n.parse_accept_lang(self.email_lang or 'en')
locale = i18n.match_lang(langs)
i18n.add_helpers_to_context(self._tell_sentry, context, locale)
context['escape'] = lambda s: s
context_html = dict(context)
i18n.add_helpers_to_context(self._tell_sentry, context_html, locale)
context_html['escape'] = htmlescape
spt = self._emails[spt_name]
base_spt = self._emails['base']
def render(t, context):
b = base_spt[t].render(context).strip()
return b.replace('$body', spt[t].render(context).strip())

message = {}
message['Source'] = 'Gratipay Support <support@gratipay.com>'
message['Destination'] = {}
message['Destination']['ToAddresses'] = ["%s <%s>" % (self.username, email)] # "Name <email@domain.com>"
message['Message'] = {}
message['Message']['Subject'] = {}
message['Message']['Subject']['Data'] = spt['subject'].render(context).strip()
message['Message']['Body'] = {
'Text': {
'Data': render('text/plain', context)
},
'Html': {
'Data': render('text/html', context_html)
}
}

self._mailer.send_email(**message)
return 1 # Sent

def queue_email(self, spt_name, **context):
self.db.run("""
INSERT INTO email_queue
(participant, spt_name, context)
VALUES (%s, %s, %s)
""", (self.id, spt_name, pickle.dumps(context)))

@classmethod
def dequeue_emails(cls):
fetch_messages = lambda: cls.db.all("""
SELECT *
FROM email_queue
ORDER BY id ASC
LIMIT 60
""")
nsent = 0
while True:
messages = fetch_messages()
if not messages:
break
for msg in messages:
p = cls.from_id(msg.participant)
r = p.send_email(msg.spt_name, **pickle.loads(msg.context))
cls.db.run("DELETE FROM email_queue WHERE id = %s", (msg.id,))
if r == 1:
sleep(1)
nsent += r
return nsent

def set_email_lang(self, accept_lang):
if not accept_lang:
return
self.db.run("UPDATE participants SET email_lang=%s WHERE id=%s",
(accept_lang, self.id))
self.set_attributes(email_lang=accept_lang)


# Notifications
# =============

Expand Down

0 comments on commit 53a7f29

Please sign in to comment.