diff --git a/branch.sql b/branch.sql
new file mode 100644
index 0000000000..602ecd7e4b
--- /dev/null
+++ b/branch.sql
@@ -0,0 +1,30 @@
+BEGIN;
+ CREATE TYPE email_address_with_confirmation AS
+ (
+ address text,
+ confirmed boolean
+ );
+
+ ALTER TABLE participants ADD email email_address_with_confirmation
+ DEFAULT NULL;
+
+ CREATE TABLE emails
+ ( id serial PRIMARY KEY
+ , email email_address_with_confirmation NOT NULL
+ , ctime timestamp with time zone NOT NULL
+ DEFAULT CURRENT_TIMESTAMP
+ , participant text NOT NULL
+ REFERENCES participants
+ ON UPDATE CASCADE
+ ON DELETE RESTRICT
+ );
+
+
+ CREATE RULE log_email_changes AS ON UPDATE
+ TO participants WHERE (OLD.email IS NULL AND NOT NEW.email IS NULL)
+ OR (NEW.email IS NULL AND NOT OLD.email IS NULL)
+ OR NEW.email <> OLD.email
+ DO INSERT INTO emails (email, participant)
+ VALUES (NEW.email, OLD.username);
+
+END;
diff --git a/gittip/models/email_address_with_confirmation.py b/gittip/models/email_address_with_confirmation.py
new file mode 100644
index 0000000000..9996e87e6a
--- /dev/null
+++ b/gittip/models/email_address_with_confirmation.py
@@ -0,0 +1,6 @@
+from postgres.orm import Model
+
+
+class EmailAddressWithConfirmation(Model):
+
+ typname = "email_address_with_confirmation"
diff --git a/gittip/models/participant.py b/gittip/models/participant.py
index 14ecc8ebb4..90b1eeda7a 100644
--- a/gittip/models/participant.py
+++ b/gittip/models/participant.py
@@ -290,6 +290,13 @@ def change_username(self, suggested):
self.set_attributes(username=suggested, username_lower=lowercased)
+ def change_email(self, email, confirmed=False):
+ gittip.db.run("UPDATE participants "
+ "SET email = ROW(%s, %s) WHERE username=%s",
+ (email, confirmed, self.username))
+ self.set_attributes(email=(email, confirmed))
+
+
def update_goal(self, goal):
typecheck(goal, (Decimal, None))
self.db.run( "UPDATE participants SET goal=%s WHERE username=%s"
diff --git a/gittip/wireup.py b/gittip/wireup.py
index f2b02eea48..02deede417 100644
--- a/gittip/wireup.py
+++ b/gittip/wireup.py
@@ -12,6 +12,7 @@
import gittip.utils.mixpanel
from gittip.models.community import Community
from gittip.models.participant import Participant
+from gittip.models.email_address_with_confirmation import EmailAddressWithConfirmation
from postgres import Postgres
@@ -32,6 +33,7 @@ def db():
db.register_model(Community)
db.register_model(Participant)
+ db.register_model(EmailAddressWithConfirmation)
return db
diff --git a/templates/connected-accounts.html b/templates/connected-accounts.html
index 9fc0eaf704..717a29ade2 100644
--- a/templates/connected-accounts.html
+++ b/templates/connected-accounts.html
@@ -127,6 +127,31 @@
Connected Accounts
>on Balanced Payments{% else %}on Balanced Payments{% end %}
+ {% if user.participant == participant or user.ADMIN %}
+
+
+
+ |
+
+
+ {% if participant.email is None %}
+ Link an email address
+ {% else %}
+ {{ participant.email.address }}
+ {% end %}
+
+ (not publicly viewable)
+ |
+
+ {% end %}
diff --git a/tests/test_email_json.py b/tests/test_email_json.py
new file mode 100644
index 0000000000..187e308d6b
--- /dev/null
+++ b/tests/test_email_json.py
@@ -0,0 +1,103 @@
+from __future__ import unicode_literals
+from nose.tools import assert_equal
+
+import json
+
+from aspen.utils import utcnow
+from gittip.testing import Harness
+from gittip.testing.client import TestClient
+
+class TestMembernameJson(Harness):
+
+ def make_client_and_csrf(self):
+ client = TestClient()
+
+ csrf_token = client.get('/').request.context['csrf_token']
+
+ return client, csrf_token
+
+
+ def test_get_returns_405(self):
+ client, csrf_token = self.make_client_and_csrf()
+
+ self.make_participant("alice", claimed_time=utcnow())
+
+ response = client.get('/alice/email.json')
+
+ actual = response.code
+ assert actual == 405, actual
+
+ def test_post_anon_returns_401(self):
+ client, csrf_token = self.make_client_and_csrf()
+
+ self.make_participant("alice", claimed_time=utcnow())
+
+ response = client.post('/alice/email.json'
+ , { 'csrf_token': csrf_token })
+
+ actual = response.code
+ assert actual == 401, actual
+
+ def test_post_with_no_email_returns_400(self):
+ client, csrf_token = self.make_client_and_csrf()
+
+ self.make_participant("alice", claimed_time=utcnow())
+
+ response = client.post('/alice/email.json'
+ , { 'csrf_token': csrf_token }
+ , user='alice'
+ )
+
+ actual = response.code
+ assert actual == 400, actual
+
+ def test_post_with_no_at_in_email_returns_400(self):
+ client, csrf_token = self.make_client_and_csrf()
+
+ self.make_participant("alice", claimed_time=utcnow())
+
+ response = client.post('/alice/email.json'
+ , {
+ 'csrf_token': csrf_token
+ , 'email': 'bademail.com'
+ }
+ , user='alice'
+ )
+
+ actual = response.code
+ assert actual == 400, actual
+
+ def test_post_with_no_dot_in_email_returns_400(self):
+ client, csrf_token = self.make_client_and_csrf()
+
+ self.make_participant("alice", claimed_time=utcnow())
+
+ response = client.post('/alice/email.json'
+ , {
+ 'csrf_token': csrf_token
+ , 'email': 'bad@emailcom'
+ }
+ , user='alice'
+ )
+
+ actual = response.code
+ assert actual == 400, actual
+
+ def test_post_with_good_email_is_success(self):
+ client, csrf_token = self.make_client_and_csrf()
+
+ self.make_participant("alice", claimed_time=utcnow())
+
+ response = client.post('/alice/email.json'
+ , {
+ 'csrf_token': csrf_token
+ , 'email': 'good@email.com'
+ }
+ , user='alice'
+ )
+
+ actual = response.code
+ assert actual == 200, actual
+
+ actual = json.loads(response.body)['email']
+ assert actual == 'good@email.com', actual
\ No newline at end of file
diff --git a/tests/test_participant.py b/tests/test_participant.py
index dcdb9f1275..6f5bff234b 100644
--- a/tests/test_participant.py
+++ b/tests/test_participant.py
@@ -134,6 +134,18 @@ def test_john_is_plural(self):
actual = Participant.from_username('john').IS_PLURAL
assert_equals(actual, expected)
+ def test_can_change_email(self):
+ Participant.from_username('alice').change_email('alice@gmail.com')
+ expected = 'alice@gmail.com'
+ actual = Participant.from_username('alice').email.address
+ assert_equals(actual, expected)
+
+ def test_can_confirm_email(self):
+ Participant.from_username('alice').change_email('alice@gmail.com', True)
+ expected = True
+ actual = Participant.from_username('alice').email.confirmed
+ assert_equals(actual, expected)
+
def test_cant_take_over_claimed_participant_without_confirmation(self):
bob_twitter = StubAccount('twitter', '2')
with assert_raises(NeedConfirmation):
diff --git a/www/%username/email.json.spt b/www/%username/email.json.spt
new file mode 100644
index 0000000000..c30283aee5
--- /dev/null
+++ b/www/%username/email.json.spt
@@ -0,0 +1,48 @@
+"""
+Change the currently authenticated user's email address.
+This will need to send a confirmation email in the future.
+"""
+import json
+
+from aspen import Response
+
+
+
+[-----------------------------------------]
+
+if not POST:
+ body = {'error': 'HTTP method must be POST'}
+ response = Response(405, json.dumps(body))
+ # We can't raise() this response obj because
+ # aspen will ignore our custom body.
+ # We must ensure it's named response at the end of the file
+ # (see raising responses http://aspen.io/api/response/)
+
+elif user.ANON:
+ body = {"error": "you're not authenticated"}
+ response = Response(401, json.dumps(body))
+
+elif 'email' not in request.body:
+ body = {'error': 'missing required parameters'}
+ response = Response(400, json.dumps(body))
+
+else:
+ # regex validation of email gets real messy
+ # if you want to take care of all edge cases
+
+ # there's not much point to it if we're making
+ # users confirm their email addresses anyway
+
+ # but we should at least confirm the presence
+ # of a '@' and '.'
+
+ address = request.body['email']
+
+ if not '@' in address or not '.' in address:
+ body = {'error': 'invalid email address'}
+ response = Response(400, json.dumps(body))
+ else:
+ # Woohoo! valid request, store it!
+ user.participant.change_email(address)
+
+ response.body = {'email': address}
diff --git a/www/assets/%version/gittip/profile.js b/www/assets/%version/gittip/profile.js
index 834b0febfc..0303993023 100644
--- a/www/assets/%version/gittip/profile.js
+++ b/www/assets/%version/gittip/profile.js
@@ -363,4 +363,43 @@ $(document).ready(function()
.on('click', '.recreate', function () {
$.post('api-key.json', { action: 'show' }, $('.api-key').data('callback'));
});
+
+
+ // Wire up email address input.
+ // ============================
+
+ $('a.email').click(function()
+ {
+ // "Link email address" text or existing
+ // email was clicked, show the text box
+ $('.email').toggle();
+ $('input.email').focus();
+ });
+
+ $('.email-submit')
+ .on('click', '[type=submit]', function () {
+ var $this = $(this);
+
+ $this.text('Saving...');
+
+ $.post('email.json'
+ , { email: $('input.email').val() }
+ , 'json'
+ ).done(function (data) {
+ $('a.email').text(data.email);
+ $('.email').toggle();
+ $this.text('Save');
+ }).fail(function (data) {
+ $this.text('Save');
+ alert('Failed to save your email address. '
+ + 'Please try again.');
+ });
+
+ return false;
+ })
+ .on('click', '[type=cancel]', function () {
+ $('.email').toggle();
+
+ return false;
+ });
});