Repository: liberapay/liberapay.com

Issue:
Welcome email should not reference the account number
The email I received after signing up says "We've received a request to connect XXX to the ~YYY account on Liberapay. Sound familiar?"

It doesn't sound familiar as it's a new account, and the "YYY" account number means nothing to me as I've just signed up. The email should be more along the lines of the familiar "Welcome to liberapy etc. etc. If this is not you, please ignore etc."
Email address is exposed in verification URL
This is a small privacy issue, it means the email address shows up in browser history and server logs. We should use the primary key of the `emails` table instead (the `id` column).

We don't have a privacy label so I'm tagging this as self-defense for now.


Discussion:
Agreed, some email templates are legacies that aren't worded appropriately with regards to how Liberapay works now.
I agree. It's kind of hard to decide if you are all new to the whole scenario. I think a guided tour approach to show some options and possibilities can be rather encouraging.
+1 in an email to support


Resolving patch:
diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py
--- a/liberapay/models/participant.py
+++ b/liberapay/models/participant.py
@@ -50,7 +50,7 @@
 from liberapay.models.exchange_route import ExchangeRoute
 from liberapay.security.crypto import constant_time_compare
 from liberapay.utils import (
-    b64encode_s, deserialize, erase_cookie, serialize, set_cookie,
+    deserialize, erase_cookie, serialize, set_cookie,
     emails, i18n, markdown,
 )
 from liberapay.website import website
@@ -61,6 +61,7 @@ class Participant(Model, MixinTeam):
     typname = 'participants'
 
     ANON = False
+    EMAIL_VERIFICATION_TIMEOUT = EMAIL_VERIFICATION_TIMEOUT
 
     def __eq__(self, other):
         if not isinstance(other, Participant):
@@ -588,34 +589,36 @@ def add_email(self, email, cursor=None):
         if len(self.get_emails()) > 9:
             raise TooManyEmailAddresses(email)
 
-        nonce = str(uuid.uuid4())
         added_time = utcnow()
         try:
             with self.db.get_cursor(cursor) as c:
                 self.add_event(c, 'add_email', email)
-                c.run("""
+                email_row = c.one("""
                     INSERT INTO emails
                                 (address, nonce, added_time, participant)
                          VALUES (%s, %s, %s, %s)
-                """, (email, nonce, added_time, self.id))
+                      RETURNING *
+                """, (email, str(uuid.uuid4()), added_time, self.id))
         except IntegrityError:
-            nonce = (cursor or self.db).one("""
+            email_row = (cursor or self.db).one("""
                 UPDATE emails
                    SET added_time=%s
                  WHERE participant=%s
                    AND address=%s
                    AND verified IS NULL
-             RETURNING nonce
+             RETURNING *
             """, (added_time, self.id, email))
-            if not nonce:
+            if not email_row:
                 return self.add_email(email)
 
+        old_email = self.email or self.get_any_email()
         scheme = website.canonical_scheme
         host = website.canonical_host
         username = self.username
-        base64_email = b64encode_s(email)
-        link = "{scheme}://{host}/{username}/emails/verify.html?email64={base64_email}&nonce={nonce}"
-        r = self.send_email('verification', email, link=link.format(**locals()))
+        addr_id = email_row.id
+        nonce = email_row.nonce
+        link = "{scheme}://{host}/{username}/emails/verify.html?email={addr_id}&nonce={nonce}"
+        r = self.send_email('verification', email, link=link.format(**locals()), old_email=old_email)
         assert r == 1  # Make sure the verification email was sent
 
         if self.email:
@@ -643,7 +646,16 @@ def update_email(self, email):
     def verify_email(self, email, nonce):
         if '' in (email, nonce):
             return emails.VERIFICATION_MISSING
-        r = self.get_email(email)
+        if email.isdigit():
+            r = self.db.one("""
+                SELECT *
+                  FROM emails
+                 WHERE participant = %s
+                   AND id = %s
+            """, (self.id, email))
+            email = r.address
+        else:
+            r = self.get_email(email)
         if r is None:
             return emails.VERIFICATION_FAILED
         if r.verified:


Test patch:
diff --git a/tests/py/test_email.py b/tests/py/test_email.py
--- a/tests/py/test_email.py
+++ b/tests/py/test_email.py
@@ -25,9 +25,12 @@ def hit_email_spt(self, action, address, auth_as='alice', should_fail=False):
         auth_as = self.alice if auth_as == 'alice' else auth_as
         return P('/alice/emails/modify.json', data, auth_as=auth_as, **headers)
 
+    def get_address_id(self, addr):
+        return self.db.one("SELECT id FROM emails WHERE address = %s", (addr,))
+
     def verify_email(self, email, nonce, should_fail=False):
-        # Email address is base64 encoded in url.
-        url = '/alice/emails/verify.html?email64=%s&nonce=%s' % (b64encode_s(email), nonce)
+        addr_id = self.get_address_id(email) or ''
+        url = '/alice/emails/verify.html?email=%s&nonce=%s' % (addr_id, nonce)
         G = self.client.GxT if should_fail else self.client.GET
         return G(url, auth_as=self.alice)
 
@@ -53,19 +56,19 @@ def test_participant_cant_add_bad_email(self):
             response = self.hit_email_spt('add-email', blob, should_fail=True)
             assert response.code == 400
 
-    def test_email_address_is_base64_encoded_in_sent_verification_link(self):
+    def test_verification_link_uses_address_id(self):
         address = 'alice@gratipay.com'
-        encoded = b64encode_s(address)
         self.hit_email_spt('add-email', address)
+        addr_id = self.get_address_id(address)
         last_email = self.get_last_email()
-        assert "/alice/emails/verify.html?email64="+encoded in last_email['text']
+        assert "/alice/emails/verify.html?email=%s&" % addr_id in last_email['text']
 
     def test_adding_email_sends_verification_email(self):
         self.hit_email_spt('add-email', 'alice@example.com')
         assert self.mailer.call_count == 1
         last_email = self.get_last_email()
         assert last_email['to'][0] == 'alice <alice@example.com>'
-        expected = "We've received a request to connect alice@example.com to the alice account on Liberapay"
+        expected = "We've received a request to associate the email address alice@example.com to "
         assert expected in last_email['text']
 
     def test_adding_second_email_sends_verification_notice(self):
@@ -135,13 +138,14 @@ def test_verify_email(self):
     def test_email_verification_is_backwards_compatible(self):
         """Test email verification still works with unencoded email in verification link.
         """
-        self.hit_email_spt('add-email', 'alice@example.com')
-        nonce = self.alice.get_email('alice@example.com').nonce
-        url = '/alice/emails/verify.html?email=alice@example.com&nonce='+nonce
+        addr = 'alice@example.com'
+        self.hit_email_spt('add-email', addr)
+        nonce = self.alice.get_email(addr).nonce
+        email64 = b64encode_s(addr)
+        url = '/alice/emails/verify.html?email64=%s&nonce=%s' % (email64, nonce)
         self.client.GET(url, auth_as=self.alice)
-        expected = 'alice@example.com'
         actual = Participant.from_username('alice').email
-        assert expected == actual
+        assert addr == actual
 
     def test_verified_email_is_not_changed_after_update(self):
         self.verify_and_change_email('alice@example.com', 'alice@example.net')
@@ -246,8 +250,7 @@ def test_can_dequeue_an_email(self):
         assert self.mailer.call_count == 1
         last_email = self.get_last_email()
         assert last_email['to'][0] == 'larry <larry@example.com>'
-        expected = "connect larry"
-        assert expected in last_email['text']
+        assert last_email['subject'] == "Email address verification - Liberapay"
         assert self.db.one("SELECT spt_name FROM email_queue") is None
 
     def test_dequeueing_an_email_without_address_just_skips_it(self):
diff --git a/tests/py/test_sign_in.py b/tests/py/test_sign_in.py
--- a/tests/py/test_sign_in.py
+++ b/tests/py/test_sign_in.py
@@ -195,7 +195,7 @@ def sign_in(self, custom={}, extra={}, url='/sign-in', **kw):
         return self.client.POST(url, data, **kw)
 
     def test_sign_in(self):
-        fake_msg = Message('Connect to Liberapay account?', 'Vous avez du pain ?')
+        fake_msg = Message('Email address verification - Liberapay', 'Vous avez du pain ?')
         LOCALES['fr'].catalog[fake_msg.id].string = fake_msg.string
         r = self.sign_in(HTTP_ACCEPT_LANGUAGE='fr')
         assert r.code == 302, r.text