Repository: liberapay/liberapay.com

Issue:
SMTP exceptions aren't caught
- https://sentry.io/share/issue/3132343535362e313939323633313931/
- https://sentry.io/share/issue/3132343535362e313939323533393935/
- https://sentry.io/share/issue/3132343535362e313939323533383839/

```
SMTPServerDisconnected: please run connect() first
  File "site-packages/algorithm.py", line 321, in loop
    new_state = function(**deps.as_kwargs)
  File "liberapay/security/authentication.py", line 136, in authenticate_user_if_possible
    p = sign_in_with_form_data(body, state)
  File "liberapay/security/authentication.py", line 88, in sign_in_with_form_data
    p.add_email(email, cursor=c)
  File "liberapay/models/participant.py", line 618, in add_email
    r = self.send_email('verification', email=email, link=link.format(**locals()))
  File "liberapay/models/participant.py", line 733, in send_email
    n = website.mailer.send(**message)
  File "mailshake/mailers/base.py", line 41, in send
    return self.send_messages(EmailMessage(*args, **kwargs))
  File "mailshake/mailers/smtp.py", line 105, in send_messages
    sent = self._send(message)
  File "mailshake/mailers/smtp.py", line 126, in _send
    from_email, recipients, email_message.as_bytes()
  File "python2.7/smtplib.py", line 733, in sendmail
    (code, resp) = self.mail(from_addr, esmtp_opts)
  File "python2.7/smtplib.py", line 480, in mail
    self.putcmd("mail", "FROM:%s%s" % (quoteaddr(sender), optionlist))
  File "python2.7/smtplib.py", line 341, in putcmd
    self.send(str)
  File "python2.7/smtplib.py", line 333, in send
    raise SMTPServerDisconnected('please run connect() first')
```

```
SMTPRecipientsRefused: {u"~3921 <winter@example.com|ping-n21127.0.0.1||`ping-c21127.0.0.1`#'|ping-n21127.0.0.1||`ping-c21127.0.0.1`#\\>": (501, 'Syntax error')}
  File "site-packages/algorithm.py", line 321, in loop
    new_state = function(**deps.as_kwargs)
  File "liberapay/security/authentication.py", line 136, in authenticate_user_if_possible
    p = sign_in_with_form_data(body, state)
  File "liberapay/security/authentication.py", line 88, in sign_in_with_form_data
    p.add_email(email, cursor=c)
  File "liberapay/models/participant.py", line 618, in add_email
    r = self.send_email('verification', email=email, link=link.format(**locals()))
  File "liberapay/models/participant.py", line 733, in send_email
    n = website.mailer.send(**message)
  File "mailshake/mailers/base.py", line 41, in send
    return self.send_messages(EmailMessage(*args, **kwargs))
  File "mailshake/mailers/smtp.py", line 105, in send_messages
    sent = self._send(message)
  File "mailshake/mailers/smtp.py", line 126, in _send
    from_email, recipients, email_message.as_bytes()
  File "python2.7/smtplib.py", line 747, in sendmail
    raise SMTPRecipientsRefused(senderrs)
```

```
SMTPDataError: (554, 'Recipient format is invalid: \'["winter@example.com\'and3405=3406--"]\'')
  File "site-packages/algorithm.py", line 321, in loop
    new_state = function(**deps.as_kwargs)
  File "liberapay/security/authentication.py", line 136, in authenticate_user_if_possible
    p = sign_in_with_form_data(body, state)
  File "liberapay/security/authentication.py", line 88, in sign_in_with_form_data
    p.add_email(email, cursor=c)
  File "liberapay/models/participant.py", line 618, in add_email
    r = self.send_email('verification', email=email, link=link.format(**locals()))
  File "liberapay/models/participant.py", line 733, in send_email
    n = website.mailer.send(**message)
  File "mailshake/mailers/base.py", line 41, in send
    return self.send_messages(EmailMessage(*args, **kwargs))
  File "mailshake/mailers/smtp.py", line 105, in send_messages
    sent = self._send(message)
  File "mailshake/mailers/smtp.py", line 126, in _send
    from_email, recipients, email_message.as_bytes()
  File "python2.7/smtplib.py", line 751, in sendmail
    raise SMTPDataError(code, resp)
```


Discussion:
The solution to this may be to do a basic validation of the email address.

Resolving patch:
diff --git a/liberapay/constants.py b/liberapay/constants.py
--- a/liberapay/constants.py
+++ b/liberapay/constants.py
@@ -63,7 +63,16 @@ def check_bits(bits):
 ELSEWHERE_ACTIONS = {'connect', 'lock', 'unlock'}
 
 EMAIL_VERIFICATION_TIMEOUT = timedelta(hours=24)
-EMAIL_RE = re.compile(r'^[^@]+@[^@]+\.[^@]+$')
+EMAIL_RE = re.compile(r'''
+    # This is the regexp used by MangoPay (as of February 2017).
+    # It rejects some valid but exotic addresses.
+    # https://en.wikipedia.org/wiki/Email_address
+    ^
+    [a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*
+    @
+    ([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?
+    $
+''', re.VERBOSE)
 
 EPOCH = datetime(1970, 1, 1, 0, 0, 0, 0, utc)
 


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
@@ -1,5 +1,9 @@
-from liberapay.exceptions import CannotRemovePrimaryEmail, EmailAlreadyTaken, EmailNotVerified
-from liberapay.exceptions import TooManyEmailAddresses
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+from liberapay.exceptions import (
+    BadEmailAddress, CannotRemovePrimaryEmail, EmailAlreadyTaken,
+    EmailNotVerified, TooManyEmailAddresses,
+)
 from liberapay.models.participant import Participant
 from liberapay.testing.emails import EmailHarness
 from liberapay.utils import b64encode_s, emails
@@ -37,6 +41,18 @@ def test_participant_can_add_email(self):
         response = self.hit_email_spt('add-email', 'alice@example.com')
         assert response.text == '{}'
 
+    def test_participant_cant_add_bad_email(self):
+        bad = (
+            'a\nb@example.net',
+            'alice@ex\rample.com',
+            '\0bob@example.org',
+        )
+        for blob in bad:
+            with self.assertRaises(BadEmailAddress):
+                self.alice.add_email(blob)
+            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):
         address = 'alice@gratipay.com'
         encoded = b64encode_s(address)
@@ -183,8 +199,8 @@ def test_cannot_add_too_many_emails(self):
             self.alice.add_email('alice@example.coop')
 
     def test_email_addresses_are_normalized(self):
-        self.alice.add_email('\t Alice." and Bob"@ExAmPlE.InFo \n')
-        assert self.alice.get_any_email() == 'Alice." and Bob"@example.info'
+        self.alice.add_email('\t Alice&Bob@ExAmPlE.InFo \n')
+        assert self.alice.get_any_email() == 'Alice&Bob@example.info'
 
     def test_emails_page_shows_emails(self):
         self.verify_and_change_email('alice@example.com', 'alice@example.net')