diff --git a/emails/signin_link.spt b/emails/signin_link.spt
new file mode 100644
index 0000000000..dc4b23fbc7
--- /dev/null
+++ b/emails/signin_link.spt
@@ -0,0 +1,15 @@
+{{ _("Sign in to Gratipay") }}
+
+[---] text/html
+{{ _( "Click the button below to sign in to Gratipay. "
+ "This link will expire in 1 hour and can only be used once.") }}
+
+
+{{ _("Sign in to Gratipay") }}
+
+[---] text/plain
+
+{{ _( "Click the link below to sign in to Gratipay. "
+ "This link will expire in 1 hour and can only be used once.") }}
+
+{{ signin_link }}
diff --git a/tests/py/test_www_email_auth.py b/tests/py/test_www_email_auth.py
new file mode 100644
index 0000000000..ff5802cada
--- /dev/null
+++ b/tests/py/test_www_email_auth.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import json
+
+from gratipay.testing import Harness
+from gratipay.testing.email import QueuedEmailHarness
+
+
+class TestSendLink(Harness):
+ def test_returns_json(self):
+ self.make_participant('alice', email_address='alice@gratipay.com')
+ response = self.client.POST('/auth/email/send_link.json', {'email_address': 'alice@gratipay.com'})
+
+ message = json.loads(response.body)['message']
+ assert message == "We've sent you a link to sign in. Please check your inbox."
+
+ def test_only_allows_post(self):
+ response = self.client.GxT('/auth/email/send_link.json')
+
+ assert response.code == 405
+
+ def test_400_for_no_email_address_parameter(self):
+ response = self.client.PxST('/auth/email/send_link.json')
+
+ assert response.code == 400
+
+ def test_400_for_invalid_email(self):
+ response = self.client.PxST('/auth/email/send_link.json', {'email_address': 'dummy@gratipay.com'})
+
+ # TODO: Change this when signup links are supported
+
+ assert response.code == 400
+
+class TestSendLinkEmail(QueuedEmailHarness):
+ def test_sends_email(self):
+ self.make_participant('alice', email_address='alice@gratipay.com')
+ self.client.POST('/auth/email/send_link.json', {'email_address': 'alice@gratipay.com'})
+
+ assert self.get_last_email()['to'] == 'alice '
+ assert 'Click the link below to sign in to Gratipay' in self.get_last_email()['body_text']
+ assert 'Click the button below to sign in to Gratipay' in self.get_last_email()['body_html']
diff --git a/www/auth/email/send_link.json.spt b/www/auth/email/send_link.json.spt
new file mode 100644
index 0000000000..8ea1740a11
--- /dev/null
+++ b/www/auth/email/send_link.json.spt
@@ -0,0 +1,39 @@
+from aspen import Response
+
+from gratipay.models.participant import Participant
+from gratipay.security.authentication.email import create_signin_nonce
+from gratipay.utils import encode_for_querystring
+
+[---]
+
+request.allow("POST")
+
+if "email_address" not in request.body:
+ raise Response(400, "no 'email_address' in body")
+
+email_address = request.body["email_address"]
+
+participant = Participant.from_email(email_address)
+
+if participant:
+ nonce = create_signin_nonce(website.db, email_address)
+
+ # TODO: Catch throttled!
+
+ encoded_email = encode_for_querystring(email_address)
+
+ signin_link = "%s/auth/email/verify.html?nonce=%s&email=%s" % (website.base_url, nonce, encoded_email)
+ website.app.email_queue.put( participant
+ , "signin_link"
+ , _user_initiated=True
+ , include_unsubscribe=False
+ , email=email_address
+ , signin_link=signin_link
+ )
+ message = _("We've sent you a link to sign in. Please check your inbox.")
+else:
+ # TODO: Create sign-up link!
+ raise Response(400, "no participant exists by this address")
+
+[---] application/json via json_dump
+{"message": message}