diff --git a/README.md b/README.md index 48c73776e8..0e27e5c07e 100644 --- a/README.md +++ b/README.md @@ -223,16 +223,23 @@ The site works without this, except for the credit card page. Visit the [Balanced Documentation](https://www.balancedpayments.com/docs) if you want to know more about creating marketplaces. -The GITHUB_* keys are for a gratipay-dev application in the Gratipay organization -on Github. It points back to localhost:8537, which is where Gratipay will be -running if you start it locally with `make run`. Similarly with the TWITTER_* -keys, but there they required us to spell it `127.0.0.1`. +The `GITHUB_*` keys are for a gratipay-dev application in the Gratipay +organization on Github. It points back to localhost:8537, which is where +Gratipay will be running if you start it locally with `make run`. Similarly +with the `TWITTER_*` keys, but there they required us to spell it `127.0.0.1`. If you wish to use a different username or database name for the database, you should override the `DATABASE_URL` in `local.env` using the following format: DATABASE_URL=postgres://@localhost/ +The `MANDRILL_KEY` value in `defaults.env` is for a test mail server, which +won't actually send email to you. If you need to receive email during +development then sign up for an account of your own at +[Mandrill](http://mandrill.com/) and override `MANDRILL_KEY` in your +`local.env`. + + Vagrant ------- If you have Vagrant installed, you can run Gratipay by running `vagrant up` from the project directory. Please note that if you ever switch between running Gratipay on your own machine to Vagrant or vice versa, you will need to run `make clean`. diff --git a/branch.py b/branch.py new file mode 100644 index 0000000000..0a6ddf5187 --- /dev/null +++ b/branch.py @@ -0,0 +1,63 @@ +""" +This is a one-off script to populate the new `emails` table using the addresses +we have in `participants`. +""" +from __future__ import division, print_function, unicode_literals + +from urllib import quote +import uuid + +import gratipay +from aspen.utils import utcnow +from postgres.orm import Model + +import gratipay.wireup + +env = gratipay.wireup.env() +tell_sentry = gratipay.wireup.make_sentry_teller(env) +db = gratipay.wireup.db(env) +gratipay.wireup.mail(env) +gratipay.wireup.load_i18n('.', tell_sentry) +gratipay.wireup.canonical(env) + + +class EmailAddressWithConfirmation(Model): + typname = "email_address_with_confirmation" + +db.register_model(EmailAddressWithConfirmation) + + +def add_email(self, email): + nonce = str(uuid.uuid4()) + verification_start = utcnow() + + scheme = gratipay.canonical_scheme + host = gratipay.canonical_host + username = self.username_lower + quoted_email = quote(email) + link = "{scheme}://{host}/{username}/emails/verify.html?email={quoted_email}&nonce={nonce}" + self.send_email('initial', + email=email, + link=link.format(**locals()), + username=self.username, + include_unsubscribe=False) + + db.run(""" + INSERT INTO emails + (address, nonce, verification_start, participant) + VALUES (%s, %s, %s, %s) + """, (email, nonce, verification_start, self.username)) + + +participants = db.all(""" + SELECT p.*::participants + FROM participants p + WHERE email IS NOT NULL + AND NOT is_closed + AND is_suspicious IS NOT true + AND claimed_time IS NOT NULL; +""") +total = len(participants) +for i, p in enumerate(participants, 1): + print('sending email to %s (%i/%i)' % (p.username, i, total)) + add_email(p, p.email.address) diff --git a/branch.sql b/branch.sql new file mode 100644 index 0000000000..29c5803ab6 --- /dev/null +++ b/branch.sql @@ -0,0 +1,46 @@ +BEGIN; + CREATE TABLE emails + ( id serial PRIMARY KEY + , address text NOT NULL + , verified boolean DEFAULT NULL + CONSTRAINT verified_cant_be_false + -- Only use TRUE and NULL, so that the + -- unique constraint below functions + -- properly. + CHECK (verified IS NOT FALSE) + , nonce text + , verification_start timestamp with time zone NOT NULL + DEFAULT CURRENT_TIMESTAMP + , verification_end timestamp with time zone + , participant text NOT NULL + REFERENCES participants + ON UPDATE CASCADE + ON DELETE RESTRICT + + , UNIQUE (address, verified) -- A verified email address can't be linked to multiple + -- participants. However, an *un*verified address *can* + -- be linked to multiple participants. We implement this + -- by using NULL instead of FALSE for the unverified + -- state, hence the check constraint on verified. + , UNIQUE (participant, address) + ); + + -- The participants table currently has an `email` attribute of type + -- email_address_with confirmation. This should be deleted in the future, + -- once the emails are migrated. The column we're going to replace it with + -- is named `email_address`. This is only for **verified** emails. All + -- unverified email stuff happens in the emails table and won't touch this + -- attribute. + + ALTER TABLE participants ADD COLUMN email_address text UNIQUE, + ADD COLUMN email_lang text; + + UPDATE events + SET payload = replace(replace( payload::text, '"set"', '"add"') + , '"current_email"' + , '"email"' + )::json + WHERE payload->>'action' = 'set' + AND (payload->'values'->'current_email') IS NOT NULL; + +END; diff --git a/configure-aspen.py b/configure-aspen.py index 8c598656f3..e3d39d804e 100644 --- a/configure-aspen.py +++ b/configure-aspen.py @@ -1,6 +1,7 @@ from __future__ import division from decimal import Decimal as D +import base64 import threading import time import traceback @@ -55,7 +56,8 @@ def _set_cookie(response, *args, **kw): 'len': len, 'float': float, 'type': type, - 'str': str + 'str': str, + 'b64encode': base64.b64encode } @@ -63,11 +65,11 @@ def _set_cookie(response, *args, **kw): tell_sentry = website.tell_sentry = gratipay.wireup.make_sentry_teller(env) gratipay.wireup.canonical(env) website.db = gratipay.wireup.db(env) -website.mail = gratipay.wireup.mail(env) +website.mailer = gratipay.wireup.mail(env, website.project_root) gratipay.wireup.billing(env) gratipay.wireup.username_restrictions(website) gratipay.wireup.nanswers(env) -gratipay.wireup.load_i18n(website) +gratipay.wireup.load_i18n(website.project_root, tell_sentry) gratipay.wireup.other_stuff(website, env) gratipay.wireup.accounts_elsewhere(website, env) @@ -158,7 +160,7 @@ def add_stuff_to_context(request): , authentication.get_auth_from_request , csrf.get_csrf_token_from_request , add_stuff_to_context - , i18n.add_helpers_to_context + , i18n.set_up_i18n , algorithm['dispatch_request_to_filesystem'] diff --git a/defaults.env b/defaults.env index 5adef34f31..831575dcd8 100644 --- a/defaults.env +++ b/defaults.env @@ -71,6 +71,6 @@ ASPEN_WWW_ROOT=www/ # https://github.com/benoitc/gunicorn/issues/186 GUNICORN_OPTS="--workers=1 --timeout=99999999" -MANDRILL_KEY= +MANDRILL_KEY=Phh_Lm3RdPT5blqOPY4dVQ RAISE_CARD_EXPIRATION=no diff --git a/emails/base.spt b/emails/base.spt new file mode 100644 index 0000000000..dc0eea04a9 --- /dev/null +++ b/emails/base.spt @@ -0,0 +1,29 @@ +[---] text/html +
+ Gratipay +
+ +
+$body +
+ +
+
+ {{ _("Something not right? Reply to this email for help.") }} +
+
+ Sent by Gratipay, LLC | 716 Park Road, Ambridge, PA, 15003, USA +
+
+ +[---] text/plain +{{ _("Greetings, program!") }} + +$body + +{{ _("Something not right? Reply to this email for help.") }} + +---- + +Sent by Gratipay, LLC, https://gratipay.com/ +716 Park Road, Ambridge, PA, 15003, USA diff --git a/emails/initial.spt b/emails/initial.spt new file mode 100644 index 0000000000..f5cb5ca152 --- /dev/null +++ b/emails/initial.spt @@ -0,0 +1,21 @@ +{{ _("Connect to {0} on Gratipay?", username) }} + +[---] text/html +A while ago we received a request to connect {{ escape(email) }} to the +{{ '{0}'.format(username) }} +account on Gratipay (formerly +Gittip). Now we're finally sending a verification email! Ring a bell? +
+
+Yes, proceed! + +[---] text/plain + +A while ago we received a request to connect `{{ email }}` +to the `{{ username }}` account on Gratipay (formerly Gittip). +Now we're finally sending a verification email! + +Ring a bell? Follow this link to finish connecting your email: + +{{ link }} diff --git a/emails/verification.spt b/emails/verification.spt new file mode 100644 index 0000000000..e8c4e7bc0c --- /dev/null +++ b/emails/verification.spt @@ -0,0 +1,17 @@ +{{ _("Connect to {0} on Gratipay?", username) }} + +[---] text/html +{{ _("We've received a request to connect {0} to the {1} account on Gratipay. Sound familiar?", + '%s' % escape(email), + '{0}'.format(username)) }} +
+
+{{ _("Yes, proceed!") }} + +[---] text/plain +{{ _("We've received a request to connect {0} to the {1} account on Gratipay. Sound familiar?", + email, username) }} + +{{ _("Follow this link to finish connecting your email:") }} + +{{ link }} diff --git a/emails/verification_notice.spt b/emails/verification_notice.spt new file mode 100644 index 0000000000..e02aad51f8 --- /dev/null +++ b/emails/verification_notice.spt @@ -0,0 +1,13 @@ +{{ _("Connecting {0} to {1} on Gratipay.", new_email, username) }} + +[---] text/html +{{ _("We are connecting {0} to the {1} account on Gratipay. This is a notification " + "sent to {2} because that is the primary email address we have on file.", + '%s' % escape(new_email), + '{0}'.format(username), + '%s' % escape(email)) }} + +[---] text/plain +{{ _("We are connecting {0} to the {1} account on Gratipay. This is a notification " + "sent to {2} because that is the primary email address we have on file.", + new_email, username, email) }} diff --git a/gratipay/exceptions.py b/gratipay/exceptions.py index 9695ad9ef4..94f2c6ff16 100644 --- a/gratipay/exceptions.py +++ b/gratipay/exceptions.py @@ -4,6 +4,8 @@ from __future__ import print_function, unicode_literals +from aspen import Response + class ProblemChangingUsername(Exception): def __str__(self): @@ -25,6 +27,23 @@ class UsernameAlreadyTaken(ProblemChangingUsername): msg = "The username '{}' is already taken." +class ProblemChangingEmail(Response): + def __init__(self, *args): + Response.__init__(self, 400, self.msg.format(*args)) + +class EmailAlreadyTaken(ProblemChangingEmail): + msg = "{} is already connected to a different Gratipay account." + +class CannotRemovePrimaryEmail(ProblemChangingEmail): + msg = "You cannot remove your primary email address." + +class EmailNotVerified(ProblemChangingEmail): + msg = "The email address '{}' is not verified." + +class TooManyEmailAddresses(ProblemChangingEmail): + msg = "You've reached the maximum number of email addresses we allow." + + class ProblemChangingNumber(Exception): def __str__(self): return self.msg diff --git a/gratipay/models/email_address_with_confirmation.py b/gratipay/models/email_address_with_confirmation.py deleted file mode 100644 index 9996e87e6a..0000000000 --- a/gratipay/models/email_address_with_confirmation.py +++ /dev/null @@ -1,6 +0,0 @@ -from postgres.orm import Model - - -class EmailAddressWithConfirmation(Model): - - typname = "email_address_with_confirmation" diff --git a/gratipay/models/participant.py b/gratipay/models/participant.py index 6cd4aba32e..9c501aae2a 100644 --- a/gratipay/models/participant.py +++ b/gratipay/models/participant.py @@ -10,7 +10,10 @@ """ from __future__ import print_function, unicode_literals +from cgi import escape +from datetime import timedelta from decimal import Decimal, ROUND_DOWN, ROUND_HALF_EVEN +from urllib import quote import uuid import aspen @@ -31,13 +34,18 @@ NoTippee, BadAmount, UserDoesntAcceptTips, + EmailAlreadyTaken, + CannotRemovePrimaryEmail, + EmailNotVerified, + TooManyEmailAddresses, ) from gratipay.models import add_event from gratipay.models._mixin_team import MixinTeam from gratipay.models.account_elsewhere import AccountElsewhere +from gratipay.security.crypto import constant_time_compare +from gratipay.utils import i18n, is_card_expiring, emails from gratipay.utils.username import safely_reserve_a_username -from gratipay.utils import is_card_expiring ASCII_ALLOWED_IN_USERNAME = set("0123456789" @@ -46,6 +54,8 @@ ".,-_:@ ") # We use | in Sentry logging, so don't make that allowable. :-) +EMAIL_HASH_TIMEOUT = timedelta(hours=24) + NANSWERS_THRESHOLD = 0 # configured in wireup.py NOTIFIED_ABOUT_EXPIRATION = b'notifiedAboutExpiration' @@ -419,6 +429,8 @@ def clear_personal_information(self, cursor): AND is_member IS true ); + DELETE FROM emails WHERE participant = %(username)s; + UPDATE participants SET statement='' , goal=NULL @@ -426,7 +438,7 @@ def clear_personal_information(self, cursor): , anonymous_receiving=False , number='singular' , avatar_url=NULL - , email=NULL + , email_address=NULL , claimed_time=NULL , session_token=NULL , session_expires=now() @@ -441,6 +453,165 @@ def clear_personal_information(self, cursor): self.set_attributes(**r._asdict()) + # Emails + # ====== + + def add_email(self, email): + """ + 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 participant + FROM emails + WHERE address = %(email)s + AND 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() + 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) + VALUES (%s, %s, %s, %s) + """, (email, nonce, verification_start, self.username)) + except IntegrityError: + nonce = self.db.one(""" + UPDATE emails + SET verification_start=%s + WHERE participant=%s + AND address=%s + AND verified IS NULL + RETURNING nonce + """, (verification_start, self.username, email)) + if not nonce: + return self.add_email(email) + + scheme = gratipay.canonical_scheme + host = gratipay.canonical_host + username = self.username_lower + quoted_email = quote(email) + link = "{scheme}://{host}/{username}/emails/verify.html?email={quoted_email}&nonce={nonce}" + self.send_email('verification', + email=email, + link=link.format(**locals()), + include_unsubscribe=False) + 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 or not constant_time_compare(r.nonce, nonce): + return emails.VERIFICATION_FAILED + if r.verified: + return emails.VERIFICATION_REDUNDANT + 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=%s + AND address=%s + AND verified IS NULL + """, (self.username, 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=%s + AND address=%s + """, (self.username, email)) + + def get_emails(self): + return self.db.all(""" + SELECT * + FROM emails + WHERE participant=%s + ORDER BY id + """, (self.username,)) + + 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=%s AND address=%s", + (self.username, address)) + + def send_email(self, spt_name, accept_lang=None, **context): + context['escape'] = escape + context['username'] = self.username + context.setdefault('include_unsubscribe', True) + email = context.setdefault('email', self.email_address) + langs = i18n.parse_accept_lang(accept_lang or self.email_lang or 'en') + locale = i18n.match_lang(langs) + i18n.add_helpers_to_context(self._tell_sentry, context, locale) + spt = self._emails[spt_name] + base_spt = self._emails['base'] + def render(t): + b = base_spt[t].render(context).strip() + return b.replace('$body', spt[t].render(context).strip()) + message = {} + message['from_email'] = 'support@gratipay.com' + message['from_name'] = 'Gratipay Support' + message['to'] = [{'email': email, 'name': self.username}] + message['subject'] = spt['subject'].render(context) + message['html'] = render('text/html') + message['text'] = render('text/plain') + + return self._mailer.messages.send(message=message) + + 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) + + # Random Junk # =========== @@ -546,14 +717,6 @@ def update_avatar(self): """, (self.username,)) self.set_attributes(avatar_url=avatar_url) - def update_email(self, email, confirmed=False): - with self.db.get_cursor() as c: - add_event(c, 'participant', dict(id=self.id, action='set', values=dict(current_email=email))) - r = c.one("UPDATE participants SET email = ROW(%s, %s) WHERE username=%s RETURNING email" - , (email, confirmed, self.username) - ) - self.set_attributes(email=r) - def update_goal(self, goal): typecheck(goal, (Decimal, None)) with self.db.get_cursor() as c: @@ -1179,6 +1342,24 @@ def take_over(self, account, have_confirmation=False): """ + MERGE_EMAIL_ADDRESSES = """ + + WITH emails_to_keep AS ( + SELECT DISTINCT ON (address) id + FROM emails + WHERE participant IN (%(dead)s, %(live)s) + ORDER BY address, verification_end, verification_start DESC + ) + DELETE FROM emails + WHERE participant IN (%(dead)s, %(live)s) + AND id NOT IN (SELECT id FROM emails_to_keep); + + UPDATE emails + SET participant = %(live)s + WHERE participant = %(dead)s; + + """ + new_balance = None with self.db.get_cursor() as cursor: @@ -1305,6 +1486,11 @@ def take_over(self, account, have_confirmation=False): other.set_attributes(balance=archive_balance) new_balance = cursor.one(TRANSFER_BALANCE_2, args) + # Take over email addresses. + # ========================== + + cursor.run(MERGE_EMAIL_ADDRESSES, dict(live=x, dead=y)) + # Disconnect any remaining elsewhere account. # =========================================== diff --git a/gratipay/utils/emails.py b/gratipay/utils/emails.py new file mode 100644 index 0000000000..e932accb16 --- /dev/null +++ b/gratipay/utils/emails.py @@ -0,0 +1,28 @@ +from __future__ import unicode_literals + +from aspen.resources.pagination import parse_specline, split_and_escape +from aspen_jinja2_renderer import SimplateLoader +from jinja2 import Environment + + +( VERIFICATION_MISSING +, VERIFICATION_FAILED +, VERIFICATION_EXPIRED +, VERIFICATION_REDUNDANT +, VERIFICATION_STYMIED +, VERIFICATION_SUCCEEDED + ) = range(6) + + +jinja_env = Environment() + +def compile_email_spt(fpath): + r = {} + with open(fpath) as f: + pages = list(split_and_escape(f.read())) + for i, page in enumerate(pages, 1): + tmpl = b'\n' * page.offset + page.content + content_type, renderer = parse_specline(page.header) + key = 'subject' if i == 1 else content_type + r[key] = SimplateLoader(fpath, tmpl).load(jinja_env, fpath) + return r diff --git a/gratipay/utils/i18n.py b/gratipay/utils/i18n.py index 4b2ed6bd6c..3e746dc768 100644 --- a/gratipay/utils/i18n.py +++ b/gratipay/utils/i18n.py @@ -19,6 +19,8 @@ ALIASES = {k: v.lower() for k, v in LOCALE_ALIASES.items()} ALIASES_R = {v: k for k, v in ALIASES.items()} +LOCALES = {} +LOCALE_EN = None ternary_re = re.compile(r'^\(? *(.+?) *\? *(.+?) *: *(.+?) *\)?$') @@ -38,7 +40,7 @@ def get_function_from_rule(rule): return eval('lambda n: ' + rule, {'__builtins__': {}}) -def get_text(request, loc, s, *a, **kw): +def get_text(loc, s, *a, **kw): msg = loc.catalog.get(s) if msg: s = msg.string or s @@ -49,7 +51,7 @@ def get_text(request, loc, s, *a, **kw): return s -def n_get_text(website, request, loc, s, p, n, *a, **kw): +def n_get_text(tell_sentry, request, loc, s, p, n, *a, **kw): n = n or 0 msg = loc.catalog.get((s, p)) s2 = None @@ -57,7 +59,7 @@ def n_get_text(website, request, loc, s, p, n, *a, **kw): try: s2 = msg.string[loc.catalog.plural_func(n)] except Exception as e: - website.tell_sentry(e, request) + tell_sentry(e, request) if s2 is None: loc = 'en' s2 = s if n == 1 else p @@ -84,7 +86,7 @@ def regularize_locale(loc): def regularize_locales(locales): - """Yield locale strings in the same format as they are in website.locales. + """Yield locale strings in the same format as they are in LOCALES. """ locales = [regularize_locale(loc) for loc in locales] locales_set = set(locales) @@ -104,15 +106,17 @@ def strip_accents(s): return ''.join(c for c in normalize('NFKD', s) if not combining(c)) -def get_locale_for_request(request, website): - accept_lang = request.headers.get("Accept-Language", "") +def parse_accept_lang(accept_lang): languages = (lang.split(";", 1)[0] for lang in accept_lang.split(",")) - languages = request.accept_langs = regularize_locales(languages) + return regularize_locales(languages) + + +def match_lang(languages): for lang in languages: - loc = website.locales.get(lang) + loc = LOCALES.get(lang) if loc: return loc - return website.locale_en + return LOCALE_EN def format_currency_with_options(number, currency, locale='en', trailing_zeroes=True): @@ -122,12 +126,18 @@ def format_currency_with_options(number, currency, locale='en', trailing_zeroes= return s -def add_helpers_to_context(website, request): - context = request.context - loc = context['locale'] = get_locale_for_request(request, website) +def set_up_i18n(website, request): + accept_lang = request.headers.get("Accept-Language", "") + langs = request.accept_langs = parse_accept_lang(accept_lang) + loc = match_lang(langs) + add_helpers_to_context(website.tell_sentry, request.context, loc, request) + + +def add_helpers_to_context(tell_sentry, context, loc, request=None): + context['locale'] = loc context['decimal_symbol'] = get_decimal_symbol(locale=loc) - context['_'] = lambda s, *a, **kw: get_text(request, loc, s, *a, **kw) - context['ngettext'] = lambda *a, **kw: n_get_text(website, request, loc, *a, **kw) + context['_'] = lambda s, *a, **kw: get_text(loc, s, *a, **kw) + context['ngettext'] = lambda *a, **kw: n_get_text(tell_sentry, request, loc, *a, **kw) context['format_number'] = lambda *a: format_number(*a, locale=loc) context['format_decimal'] = lambda *a: format_decimal(*a, locale=loc) context['format_currency'] = lambda *a, **kw: format_currency_with_options(*a, locale=loc, **kw) diff --git a/gratipay/wireup.py b/gratipay/wireup.py index e2f9b28080..7898f04724 100644 --- a/gratipay/wireup.py +++ b/gratipay/wireup.py @@ -29,13 +29,12 @@ from gratipay.models.account_elsewhere import AccountElsewhere from gratipay.models.community import Community from gratipay.models.participant import Participant -from gratipay.models.email_address_with_confirmation import EmailAddressWithConfirmation from gratipay.models import GratipayDB -from gratipay.utils import COUNTRIES, COUNTRIES_MAP +from gratipay.utils import COUNTRIES, COUNTRIES_MAP, i18n from gratipay.utils.cache_static import asset_etag +from gratipay.utils.emails import compile_email_spt from gratipay.utils.i18n import ALIASES, ALIASES_R, get_function_from_rule, strip_accents - def canonical(env): gratipay.canonical_scheme = env.canonical_scheme gratipay.canonical_host = env.canonical_host @@ -46,18 +45,21 @@ def db(env): maxconn = env.database_maxconn db = GratipayDB(dburl, maxconn=maxconn) - db.register_model(Community) - db.register_model(AccountElsewhere) - db.register_model(Participant) - db.register_model(EmailAddressWithConfirmation) + for model in (Community, AccountElsewhere, Participant): + db.register_model(model) gratipay.billing.payday.Payday.db = db return db -def mail(env): - mandrill_client = mandrill.Mandrill(env.mandrill_key) - - return mandrill_client +def mail(env, project_root='.'): + Participant._mailer = mandrill.Mandrill(env.mandrill_key) + emails = {} + emails_dir = project_root+'/emails/' + i = len(emails_dir) + for spt in find_files(emails_dir, '*.spt'): + base_name = spt[i:-4] + emails[base_name] = compile_email_spt(spt) + Participant._emails = emails def billing(env): balanced.configure(env.balanced_api_secret) @@ -73,6 +75,7 @@ def make_sentry_teller(env): aspen.log_dammit("Won't log to Sentry (SENTRY_DSN is empty).") def noop(exception, request=None): pass + Participant._tell_sentry = noop return noop sentry = raven.Client(env.sentry_dsn) @@ -152,6 +155,7 @@ def tell_sentry(exception, request=None): ident = sentry.get_ident(result) aspen.log_dammit('Exception reference: ' + ident) + Participant._tell_sentry = tell_sentry return tell_sentry @@ -259,11 +263,11 @@ def clean_assets(website): pass -def load_i18n(website): +def load_i18n(project_root, tell_sentry): # Load the locales key = lambda t: strip_accents(t[1]) - localeDir = os.path.join(website.project_root, 'i18n', 'core') - locales = website.locales = {} + localeDir = os.path.join(project_root, 'i18n', 'core') + locales = i18n.LOCALES for file in os.listdir(localeDir): try: parts = file.split(".") @@ -281,10 +285,10 @@ def load_i18n(website): l.countries_map = COUNTRIES_MAP l.countries = COUNTRIES except Exception as e: - website.tell_sentry(e) + tell_sentry(e) # Add the default English locale - locale_en = website.locale_en = locales['en'] = Locale('en') + locale_en = i18n.LOCALE_EN = locales['en'] = Locale('en') locale_en.catalog = Catalog('en') locale_en.catalog.plural_func = lambda n: n != 1 locale_en.countries = COUNTRIES diff --git a/js/gratipay/account.js b/js/gratipay/account.js index 48d9f9856d..c0b5ea3192 100644 --- a/js/gratipay/account.js +++ b/js/gratipay/account.js @@ -1,5 +1,44 @@ Gratipay.account = {}; +Gratipay.account.post_email = function(e) { + e.preventDefault(); + var $this = $(this); + var action = this.className; + var $inputs = $('.emails button, .emails input'); + console.log($this); + var address = $this.parent().data('email') || $('input.add-email').val(); + + $inputs.prop('disabled', true); + + $.ajax({ + url: '../emails/modify.json', + type: 'POST', + data: {action: action, address: address}, + dataType: 'json', + success: function (msg) { + if (msg) { + Gratipay.notification(msg, 'success'); + } + if (action == 'add-email') { + $('input.add-email').val(''); + setTimeout(function(){ window.location.reload(); }, 3000); + return; + } else if (action == 'set-primary') { + $('.emails li').removeClass('primary'); + $this.parent().addClass('primary'); + } else if (action == 'remove') { + $this.parent().fadeOut(); + } + $inputs.prop('disabled', false); + }, + error: function (e) { + $inputs.prop('disabled', false); + error_message = JSON.parse(e.responseText).error_message_long; + Gratipay.notification(error_message || "Failure", 'error'); + }, + }); +}; + Gratipay.account.init = function() { // Wire up username knob. @@ -119,53 +158,13 @@ Gratipay.account.init = function() { $.post('../api-key.json', {action: 'show'}, callback); }); - // Wire up email address input. - // ============================ - $('.toggle-email').on("click", function() { - $('.email').toggle(); - $('.toggle-email').hide(); - $('input.email').focus(); - }); - - // Wire up email form. - $('.email-submit').on('click', '[type=submit]', function() { - var $this = $(this); - - $this.css('opacity', 0.5); - function success(data) { - $('.email-address').text(data.email); - $('.email').toggle(); - $('.toggle-email').show(); - if (data.email === '') { - $('.toggle-email').text('+ Add'); // TODO i18n - } else { - $('.toggle-email').text('Edit'); // TODO i18n - } - $this.css('opacity', 1); - } - - $.ajax({ - url: '../email.json', - type: 'POST', - dataType: 'json', - success: success, - error: function (data) { - $this.css('opacity', 1); - Gratipay.notification('Failed to save your email address. ' - + 'Please try again.', 'error'); - }, - data: {email: $('input.email').val()} - }) - - return false; - }) - .on('click', '[type=cancel]', function () { - $('.email').toggle(); - $('.toggle-email').show(); + // Wire up email addresses list. + // ============================= - return false; - }); + $('.emails button, .emails input').prop('disabled', false); + $('.emails button[class]').on('click', Gratipay.account.post_email); + $('form.add-email').on('submit', Gratipay.account.post_email); // Wire up close knob. diff --git a/js/gratipay/profile.js b/js/gratipay/profile.js index 6a2d258cdb..a73ab36152 100644 --- a/js/gratipay/profile.js +++ b/js/gratipay/profile.js @@ -201,10 +201,10 @@ Gratipay.profile.init = function() { $('.toggle-bitcoin').show(); $('.bitcoin').toggle(); if (d.bitcoin_address === '') { - $('.toggle-bitcoin').text('+ Add'); // TODO i18n + $('.toggle-bitcoin').text('+ Add'); $('.bitcoin .address').attr('href', ''); } else { - $('.toggle-bitcoin').text('Edit'); // TODO i18n + $('.toggle-bitcoin').text('Edit'); $('.bitcoin .address').attr('href', 'https://blockchain.info/address/'+d.bitcoin_address); } $this.css('opacity', 1); diff --git a/scss/modules.scss b/scss/modules.scss index 56814ac88a..54fa8e5016 100644 --- a/scss/modules.scss +++ b/scss/modules.scss @@ -305,4 +305,32 @@ td.dnt-value { padding-right: 8px; } - +.emails { + .label-primary, .set-primary { display: none; } + .verified { + .label-unverified, .resend { display: none; } + .set-primary { display: inline-block; } + } + .primary { + .set-primary, .remove { display: none; } + .label-primary { display: inline; } + } + li { + list-style: square inside; + margin: 0 0 0.5em 0.5em; + } + .label-primary, .label-unverified { + font-size: 90%; + margin-left: 0.5em; + } + .label-primary { + color: #164a9a; + } + .label-unverified { + color: #aaa; + } + li button { + float: right; + margin-left: 5px; + } +} diff --git a/templates/auth.html b/templates/auth.html index f8c7954904..8e469ec5d9 100644 --- a/templates/auth.html +++ b/templates/auth.html @@ -2,7 +2,8 @@
- + {% set then=b64encode(path.raw + ('?' + qs.raw if qs else '')).strip() %} + diff --git a/templates/base.html b/templates/base.html index e3026a6edb..50be746324 100644 --- a/templates/base.html +++ b/templates/base.html @@ -142,8 +142,9 @@

{{ title }}

{% endblock %} - {% if not user.ANON and not request.line.uri.startswith('/credit-card.html') %} + {% if not user.ANON %} {% endif %} diff --git a/tests/py/test_close.py b/tests/py/test_close.py index c49fd7d77f..2e7585ec54 100644 --- a/tests/py/test_close.py +++ b/tests/py/test_close.py @@ -4,7 +4,9 @@ from decimal import Decimal as D import balanced +import mock import pytest + from gratipay.billing.payday import Payday from gratipay.exceptions import NoBalancedCustomerHref, NotWhitelisted from gratipay.models.community import Community @@ -274,7 +276,8 @@ def test_ctr_clears_multiple_tips_receiving(self): # cpi - clear_personal_information - def test_cpi_clears_personal_information(self): + @mock.patch.object(Participant, '_mailer') + def test_cpi_clears_personal_information(self, mailer): alice = self.make_participant( 'alice' , statement='not forgetting to be awesome!' , goal=100 @@ -282,7 +285,7 @@ def test_cpi_clears_personal_information(self): , anonymous_receiving=True , number='plural' , avatar_url='img-url' - , email=('alice@example.com', True) + , email_address='alice@example.com' , claimed_time='now' , session_token='deadbeef' , session_expires='2000-01-01' @@ -292,6 +295,7 @@ def test_cpi_clears_personal_information(self): , npatrons=21 ) assert Participant.from_username('alice').number == 'plural' # sanity check + alice.add_email('alice@example.net') with self.db.get_cursor() as cursor: alice.clear_personal_information(cursor) @@ -303,7 +307,7 @@ def test_cpi_clears_personal_information(self): assert (alice.anonymous_receiving, new_alice.anonymous_giving) == (False, False) assert alice.number == new_alice.number == 'singular' assert alice.avatar_url == new_alice.avatar_url == None - assert alice.email == new_alice.email == None + assert alice.email_address == new_alice.email_address == None assert alice.claimed_time == new_alice.claimed_time == None assert alice.giving == new_alice.giving == 0 assert alice.pledging == new_alice.pledging == 0 @@ -311,6 +315,7 @@ def test_cpi_clears_personal_information(self): assert alice.npatrons == new_alice.npatrons == 0 assert alice.session_token == new_alice.session_token == None assert alice.session_expires.year == new_alice.session_expires.year == date.today().year + assert not alice.get_emails() def test_cpi_clears_communities(self): alice = self.make_participant('alice') diff --git a/tests/py/test_email.py b/tests/py/test_email.py new file mode 100644 index 0000000000..83e09cffaa --- /dev/null +++ b/tests/py/test_email.py @@ -0,0 +1,172 @@ +import json + +import mock + +from gratipay.exceptions import CannotRemovePrimaryEmail, EmailAlreadyTaken, EmailNotVerified +from gratipay.exceptions import TooManyEmailAddresses +from gratipay.models.participant import Participant +from gratipay.testing import Harness +from gratipay.utils import emails + + +class TestEmail(Harness): + + def setUp(self): + self.alice = self.make_participant('alice', claimed_time='now') + + @mock.patch.object(Participant, '_mailer') + def hit_email_spt(self, action, address, mailer, user='alice', should_fail=False): + P = self.client.PxST if should_fail else self.client.POST + data = {'action': action, 'address': address} + headers = {'HTTP_ACCEPT_LANGUAGE': 'fr,en'} + return P('/alice/emails/modify.json', data, auth_as=user, **headers) + + def verify_email(self, email, nonce, username='alice', should_fail=False): + url = '/%s/emails/verify.html?email=%s&nonce=%s' % (username, email, nonce) + G = self.client.GxT if should_fail else self.client.GET + return G(url, auth_as=username) + + def verify_and_change_email(self, old_email, new_email, username='alice'): + self.hit_email_spt('add-email', old_email) + nonce = Participant.from_username(username).get_email(old_email).nonce + self.verify_email(old_email, nonce) + self.hit_email_spt('add-email', new_email) + + def test_participant_can_add_email(self): + response = self.hit_email_spt('add-email', 'alice@gratipay.com') + actual = json.loads(response.body) + assert actual + + def test_post_anon_returns_403(self): + response = self.hit_email_spt('add-email', 'anon@gratipay.com', user=None, should_fail=True) + assert response.code == 403 + + def test_post_with_no_at_symbol_is_400(self): + response = self.hit_email_spt('add-email', 'gratipay.com', should_fail=True) + assert response.code == 400 + + def test_post_with_no_period_symbol_is_400(self): + response = self.hit_email_spt('add-email', 'test@gratipay', should_fail=True) + assert response.code == 400 + + def test_verify_email_without_adding_email(self): + response = self.verify_email('', 'sample-nonce') + assert 'Missing Info' in response.body + + def test_verify_email_wrong_nonce(self): + self.hit_email_spt('add-email', 'alice@example.com') + nonce = 'fake-nonce' + r = self.alice.verify_email('alice@gratipay.com', nonce) + assert r == emails.VERIFICATION_FAILED + self.verify_email('alice@example.com', nonce) + expected = None + actual = Participant.from_username('alice').email_address + assert expected == actual + + def test_verify_email_expired_nonce(self): + address = 'alice@example.com' + self.hit_email_spt('add-email', address) + self.db.run(""" + UPDATE emails + SET verification_start = (now() - INTERVAL '25 hours') + WHERE participant = 'alice' + """) + nonce = self.alice.get_email(address).nonce + r = self.alice.verify_email(address, nonce) + assert r == emails.VERIFICATION_EXPIRED + actual = Participant.from_username('alice').email_address + assert actual == None + + def test_verify_email(self): + self.hit_email_spt('add-email', 'alice@example.com') + nonce = self.alice.get_email('alice@example.com').nonce + self.verify_email('alice@example.com', nonce) + expected = 'alice@example.com' + actual = Participant.from_username('alice').email_address + assert expected == actual + + def test_verified_email_is_not_changed_after_update(self): + self.verify_and_change_email('alice@example.com', 'alice@example.net') + expected = 'alice@example.com' + actual = Participant.from_username('alice').email_address + assert expected == actual + + def test_get_emails(self): + self.verify_and_change_email('alice@example.com', 'alice@example.net') + emails = self.alice.get_emails() + assert len(emails) == 2 + + def test_verify_email_after_update(self): + self.verify_and_change_email('alice@example.com', 'alice@example.net') + nonce = self.alice.get_email('alice@example.net').nonce + self.verify_email('alice@example.net', nonce) + expected = 'alice@example.com' + actual = Participant.from_username('alice').email_address + assert expected == actual + + def test_nonce_is_reused_when_resending_email(self): + self.hit_email_spt('add-email', 'alice@example.com') + nonce1 = self.alice.get_email('alice@example.com').nonce + self.hit_email_spt('resend', 'alice@example.com') + nonce2 = self.alice.get_email('alice@example.com').nonce + assert nonce1 == nonce2 + + @mock.patch.object(Participant, '_mailer') + def test_cannot_update_email_to_already_verified(self, mailer): + bob = self.make_participant('bob', claimed_time='now') + self.alice.add_email('alice@gratipay.com') + nonce = self.alice.get_email('alice@gratipay.com').nonce + r = self.alice.verify_email('alice@gratipay.com', nonce) + assert r == emails.VERIFICATION_SUCCEEDED + + with self.assertRaises(EmailAlreadyTaken): + bob.add_email('alice@gratipay.com') + nonce = bob.get_email('alice@gratipay.com').nonce + bob.verify_email('alice@gratipay.com', nonce) + + email_alice = Participant.from_username('alice').email_address + assert email_alice == 'alice@gratipay.com' + + @mock.patch.object(Participant, '_mailer') + def test_cannot_add_too_many_emails(self, mailer): + self.alice.add_email('alice@gratipay.com') + self.alice.add_email('alice@gratipay.net') + self.alice.add_email('alice@gratipay.org') + self.alice.add_email('alice@gratipay.co.uk') + self.alice.add_email('alice@gratipay.io') + self.alice.add_email('alice@gratipay.co') + self.alice.add_email('alice@gratipay.eu') + self.alice.add_email('alice@gratipay.asia') + self.alice.add_email('alice@gratipay.museum') + self.alice.add_email('alice@gratipay.py') + with self.assertRaises(TooManyEmailAddresses): + self.alice.add_email('alice@gratipay.coop') + + def test_account_page_shows_emails(self): + self.verify_and_change_email('alice@example.com', 'alice@example.net') + body = self.client.GET("/alice/account/", auth_as="alice").body + assert 'alice@example.com' in body + assert 'alice@example.net' in body + + def test_set_primary(self): + self.verify_and_change_email('alice@example.com', 'alice@example.net') + self.verify_and_change_email('alice@example.net', 'alice@example.org') + self.hit_email_spt('set-primary', 'alice@example.com') + + def test_cannot_set_primary_to_unverified(self): + with self.assertRaises(EmailNotVerified): + self.hit_email_spt('set-primary', 'alice@example.com') + + def test_remove_email(self): + # Can remove unverified + self.hit_email_spt('add-email', 'alice@example.com') + self.hit_email_spt('remove', 'alice@example.com') + + # Can remove verified + self.verify_and_change_email('alice@example.com', 'alice@example.net') + self.verify_and_change_email('alice@example.net', 'alice@example.org') + self.hit_email_spt('remove', 'alice@example.net') + + # Cannot remove primary + with self.assertRaises(CannotRemovePrimaryEmail): + self.hit_email_spt('remove', 'alice@example.com') diff --git a/tests/py/test_email_json.py b/tests/py/test_email_json.py deleted file mode 100644 index 7a3fd9c315..0000000000 --- a/tests/py/test_email_json.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import unicode_literals - -import json - -from gratipay.testing import Harness - -class TestMembernameJson(Harness): - - def change_email_address(self, address, user='alice', should_fail=True): - self.make_participant("alice") - - if should_fail: - response = self.client.PxST("/alice/email.json" - , {'email': address,} - , auth_as=user - ) - else: - response = self.client.POST("/alice/email.json" - , {'email': address,} - , auth_as=user - ) - return response - - def test_participant_can_change_email(self): - response = self.change_email_address('alice@gratipay.com', should_fail=False) - actual = json.loads(response.body)['email'] - assert actual == 'alice@gratipay.com', actual - - def test_post_anon_returns_404(self): - response = self.change_email_address('anon@gratipay.com', user=None) - assert response.code == 404, response.code - - def test_post_with_no_at_symbol_is_400(self): - response = self.change_email_address('gratipay.com') - assert response.code == 400, response.code - - def test_post_with_no_period_symbol_is_400(self): - response = self.change_email_address('test@gratipay') - assert response.code == 400, response.code diff --git a/tests/py/test_pages.py b/tests/py/test_pages.py index 45e3e9b1a7..7880a46e05 100644 --- a/tests/py/test_pages.py +++ b/tests/py/test_pages.py @@ -137,4 +137,4 @@ def test_giving_page_shows_cancelled(self): expected1 = "bob" expected2 = "cancelled 1 tip" assert expected1 in actual - assert expected2 in actual \ No newline at end of file + assert expected2 in actual diff --git a/tests/py/test_participant.py b/tests/py/test_participant.py index ee5c1cc449..00782ca14c 100644 --- a/tests/py/test_participant.py +++ b/tests/py/test_participant.py @@ -1,10 +1,12 @@ from __future__ import print_function, unicode_literals import datetime -import random from decimal import Decimal +import random +import mock import pytest + from aspen.utils import utcnow from gratipay import NotSane from gratipay.exceptions import ( @@ -201,6 +203,30 @@ def test_idempotent(self): alice.take_over(bob_github, have_confirmation=True) self.db.self_check() + @mock.patch.object(Participant, '_mailer') + def test_email_addresses_merging(self, mailer): + alice = self.make_participant('alice') + alice.add_email('alice@example.com') + alice.add_email('alice@example.net') + alice.add_email('alice@example.org') + alice.verify_email('alice@example.org', alice.get_email('alice@example.org').nonce) + bob_github = self.make_elsewhere('github', 2, 'bob') + bob = bob_github.opt_in('bob')[0].participant + bob.add_email('alice@example.com') + bob.verify_email('alice@example.com', bob.get_email('alice@example.com').nonce) + bob.add_email('alice@example.net') + bob.add_email('bob@example.net') + alice.take_over(bob_github, have_confirmation=True) + + alice_emails = {e.address: e for e in alice.get_emails()} + assert len(alice_emails) == 4 + assert alice_emails['alice@example.com'].verified + assert alice_emails['alice@example.org'].verified + assert not alice_emails['alice@example.net'].verified + assert not alice_emails['bob@example.net'].verified + + assert not Participant.from_id(bob.id).get_emails() + class TestParticipant(Harness): def setUp(self): @@ -221,17 +247,6 @@ def test_john_is_plural(self): actual = Participant.from_username('john').IS_PLURAL assert actual == expected - def test_can_change_email(self): - self.alice.update_email('alice@gratipay.com') - expected = 'alice@gratipay.com' - actual = self.alice.email.address - assert actual == expected - - def test_can_confirm_email(self): - self.alice.update_email('alice@gratipay.com', True) - actual = self.alice.email.confirmed - assert actual == True - def test_cant_take_over_claimed_participant_without_confirmation(self): with self.assertRaises(NeedConfirmation): self.alice.take_over(('twitter', str(self.bob.id))) diff --git a/www/%username/account/index.html.spt b/www/%username/account/index.html.spt index 2740e175c1..345887b71b 100644 --- a/www/%username/account/index.html.spt +++ b/www/%username/account/index.html.spt @@ -11,13 +11,13 @@ username = participant.username # used in footer shared with on/$platform/ # pages locked = False +emails = participant.get_emails() [-----------------------------------------------------------------------------] {% extends "templates/profile.html" %} {% block scripts %} - {{ super() }} {% endblock %} @@ -144,43 +144,27 @@ locked = False {% endif %} -

{{ _("Email Settings") }}

- - - - - - -
+
+

{{ _("Email Addresses (Private)") }}

+
    + {% for email in emails %} + {% set is_primary = email.address == participant.email_address %} +
  • + {{ email.address|e }} + {{ _("Primary") }} + {{ _("Unverified") }} + + + +
  • + {% endfor %} +
+
+ + +
+
diff --git a/www/%username/email.json.spt b/www/%username/email.json.spt deleted file mode 100644 index 612427069e..0000000000 --- a/www/%username/email.json.spt +++ /dev/null @@ -1,27 +0,0 @@ -""" -Change the currently authenticated user's email address. -This will need to send a confirmation email in the future. -""" -import json -import re - -from aspen import Response - -[-----------------------------------------] - -if user.ANON: - raise Response(404) -request.allow("POST") - -address = request.body['email'] - -# This checks for exactly one @ and at least one . after @ -# The real validation will happen when we send the email -if not re.match(r"[^@]+@[^@]+\.[^@]+", address): - raise Response(400) -else: - # Woohoo! valid request, store it! - user.participant.update_email(address) - -[---] application/json via json_dump -{'email': address} diff --git a/www/%username/emails/modify.json.spt b/www/%username/emails/modify.json.spt new file mode 100644 index 0000000000..817e7c78dc --- /dev/null +++ b/www/%username/emails/modify.json.spt @@ -0,0 +1,43 @@ +""" +Manages the authenticated user's email addresses. +""" +import re + +from aspen import Response +from gratipay.exceptions import ProblemChangingEmail +from gratipay.utils import get_participant + +email_re = re.compile(r'^[^@]+@[^@]+\.[^@]+$') + +[-----------------------------------------] + +request.allow("POST") +participant = get_participant(request, restrict=True) + +action = request.body['action'] +address = request.body['address'] + +# This checks for exactly one @ and at least one . after @ +# The real validation will happen when we send the email +if not email_re.match(address): + raise Response(400, _("Invalid email address.")) + +if not participant.email_lang: + participant.set_email_lang(request.headers.get("Accept-Language")) + +msg = None +if action in ('add-email', 'resend'): + r = participant.add_email(address) + if r: + msg = _("A verification email has been sent to {0}.", address) + else: + raise Response(400, _("You have already added and verified this address.")) +elif action == 'set-primary': + participant.update_email(address) +elif action == 'remove': + participant.remove_email(address) +else: + raise Response(400, 'unknown action "%s"' % action) + +[---] application/json via json_dump +msg diff --git a/www/%username/emails/verify.html.spt b/www/%username/emails/verify.html.spt new file mode 100644 index 0000000000..12cb168161 --- /dev/null +++ b/www/%username/emails/verify.html.spt @@ -0,0 +1,79 @@ +"""Verify a participant's email +""" +from cgi import escape +from datetime import timedelta + +from aspen import Response +from aspen.utils import utcnow +from gratipay.utils import emails, get_participant + +[-----------------------------------------------------------------------------] + +participant = get_participant(request, restrict=False) +if participant == user.participant: + email = qs.get('email', '') + nonce = qs.get('nonce', '') + result = participant.verify_email(email, nonce) + if not participant.email_lang: + participant.set_email_lang(request.headers.get("Accept-Language")) +elif not user.ANON: + raise Response(403) + + +[-----------------------------------------------------------------------------] +{% extends "templates/base.html" %} + +{% block scripts %} + +{{ super() }} +{% endblock %} + +{% block heading %} +

Verify Email

+{% endblock %} + +{% block box %} +
+ {% if user.ANON %} +

{{ _("Please Sign In") }}

+ {% include "templates/sign-in-using.html" %} + and then you'll be able to verify your email.

+ +

Thanks! :-)

+ {% else %} + {% if result == emails.VERIFICATION_SUCCEEDED %} +

{{ _("Success!") }}

+

{{ _("Your email address {0} is now connected to your Gratipay account.", + "%s" % escape(email)) }}

+ {% elif result == emails.VERIFICATION_STYMIED %} +

{{ _("Address Taken") }}

+

{{ _("The email address {0} is already connected to a different Gratipay account.", + "%s" % escape(email)) }}

+ {% elif result == emails.VERIFICATION_REDUNDANT %} +

{{ _("Already Verified") }}

+

{{ _("Your email address {0} is already connected to your Gratipay account.", + "%s" % escape(email)) }}

+ {% elif result == emails.VERIFICATION_MISSING %} +

{{ _("Missing Info") }}

+

{{ _("Sorry, that's a bad link. You'll need to view your email addresses " + "and start over.") }}

+ {% else %} + {% if result == emails.VERIFICATION_EXPIRED %} +

{{ _("Expired") }}

+

{{ _("The verification code for {0} has expired.", + "%s" % escape(email)) }}

+ {% elif result == emails.VERIFICATION_FAILED %} +

{{ _("Failure") }}

+

{{ _("The verification code for {0} is bad.", + "%s" % escape(email)) }}

+ {% endif %} +

+ {% endif %} + {{ _("View your email addresses") }}. + {% endif %} +
+{% endblock %} diff --git a/www/on/%platform/associate.spt b/www/on/%platform/associate.spt index 9c2ce889e2..98898a4a77 100644 --- a/www/on/%platform/associate.spt +++ b/www/on/%platform/associate.spt @@ -25,6 +25,7 @@ except KeyError: if not cookie_value: raise Response(400, 'Empty cookie') query_data, action, then, action_user_name = json.loads(b64decode(cookie_value)) +then = b64decode(then) # double-b64encoded to avoid other encoding issues w/ qs # Finish the auth process, the returned session is ready to use url = canonical_scheme+'://'+canonical_host+request.line.uri.raw