Index: ow/__init__.py
===================================================================
--- ow/__init__.py	(revision d6da99e1929901626b25b453791629113fafb67c)
+++ ow/__init__.py	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
@@ -37,4 +37,5 @@
     config.include('pyramid_retry')
     config.include('pyramid_zodbconn')
+    config.include('pyramid_mailer')
     config.add_static_view('static', 'static', cache_max_age=3600)
     config.add_translation_dirs(
Index: ow/mail.py
===================================================================
--- ow/mail.py	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
+++ ow/mail.py	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
@@ -0,0 +1,48 @@
+import logging
+
+from pyramid_mailer import get_mailer
+from pyramid_mailer.message import Message
+from pyramid.renderers import render
+from pyramid.i18n import TranslationStringFactory
+
+_ = TranslationStringFactory('OpenWorkouts')
+
+log = logging.getLogger(__name__)
+
+
+def idna_encode_recipients(message):
+    """
+    Given a pyramid_mailer.message.Message instance, check if the recipient(s)
+    domain name(s) contain non-ascii characters and, if so, encode the domain
+    name part of the address(es) properly
+
+    >>> from pyramid_mailer.message import Message
+    >>> message = Message(subject='s', recipients=['a@a.com'], body='b')
+    >>> assert message.recipients == ['a@a.com']
+    >>> message = idna_encode_recipients(message)
+    >>> assert message.recipients == ['a@a.com']
+    >>> message.recipients.append(u'a@\xfc.de')
+    >>> assert message.recipients == ['a@a.com', u'a@\xfc.de']
+    >>> message = idna_encode_recipients(message)
+    >>> message.recipients
+    ['a@a.com', 'a@xn--tda.de']
+    """
+    recipients = []
+    for rcpt in message.recipients:
+        username, domain = rcpt.split('@')
+        recipients.append(
+            '@'.join([username, domain.encode('idna').decode('utf-8')]))
+    message.recipients = recipients
+    return message
+
+
+def send_verification_email(request, user):
+    subject = _('Welcome to OpenWorkouts')
+    template = 'ow:templates/mail_verify_account.pt'
+    verify_link = request.resource_url(user, 'verify', user.verification_token)
+    context = {'user': user, 'verify_link': verify_link}
+    mailer = get_mailer(request)
+    body = render(template, context, request)
+    message = Message(subject=subject, recipients=[user.email], body=body)
+    message = idna_encode_recipients(message)
+    mailer.send_to_queue(message)
Index: ow/migrations/1.py
===================================================================
--- ow/migrations/1.py	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
+++ ow/migrations/1.py	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
@@ -0,0 +1,24 @@
+
+def migrate(root):
+    """
+    Adds the verified and verification token attributesfor all existing users,
+    setting verified to True (verifying all users automatically)
+
+    >>> from ow.models.root import OpenWorkouts
+    >>> from ow.models.user import User
+    >>> root = OpenWorkouts()
+    >>> user = User(email='user@example.net')
+    >>> assert getattr(user, 'verified', None) is not None
+    >>> delattr(user, 'verified')
+    >>> assert getattr(user, 'verified', None) is None
+    >>> root.add_user(user)
+    >>> assert getattr(user, 'verified', None) is None
+    >>> migrate(root)
+    >>> user = root.users[0]
+    >>> assert getattr(user, 'verified', None) is not None
+    >>> assert user.verified
+    """
+    for user in root.users:
+        if getattr(user, 'verified', None) is None:
+            user.verified = True
+            user.verification_token = None
Index: ow/models/user.py
===================================================================
--- ow/models/user.py	(revision d6da99e1929901626b25b453791629113fafb67c)
+++ ow/models/user.py	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
@@ -39,4 +39,7 @@
         self.__password = None
         self.last_workout_id = 0
+        # has this user verified his account?
+        self.verified = False
+        self.verification_token = None
         super(User, self).__init__()
 
Index: ow/templates/mail_verify_account.pt
===================================================================
--- ow/templates/mail_verify_account.pt	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
+++ ow/templates/mail_verify_account.pt	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
@@ -0,0 +1,26 @@
+<html tal:omit-tag=""
+      xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+      i18n:domain="OpenWorkouts">
+
+  <tal:t i18n:translate="">Welcome to OpenWorkouts</tal:t>
+  <tal:user tal:content="user.fullname"></tal:user>
+
+  <tal:t i18n:translate="">
+    You have joined the OpenWorkouts community successfully,
+    now please verify your account.
+  </tal:t>
+
+  <tal:t i18n:translate="">
+    Click on the following link, or copy and paste it in your browser
+    navigation bar to verify your account:
+  </tal:t>
+
+  <tal:link tal:content="verify_link"></tal:link>
+
+  <tal:t i18n:translate>
+    Thank you for joining us.
+
+    Best regards from the OpenWorkouts team.
+  </tal:t>
+
+</html>
Index: ow/templates/verify.pt
===================================================================
--- ow/templates/verify.pt	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
+++ ow/templates/verify.pt	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
@@ -0,0 +1,30 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xml:lang="en"
+      xmlns:tal="http://xml.zope.org/namespaces/tal"
+      xmlns:metal="http://xml.zope.org/namespaces/metal"
+      xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+      i18n:domain="OpenWorkouts"
+      metal:use-macro="load: base_anonymous.pt"
+      tal:attributes="lang request.locale_name">
+
+  <metal:content metal:fill-slot="content">
+
+    <div class="verify-content">
+
+      <p i18n:translate="">
+        Your email hasn't been verified. Check the url and make sure you copied
+        it exactly as it was in the verification email.
+      </p>
+
+      <tal:not-logged-in tal:condition="not request.authenticated_userid">
+        <a href="" tal:attributes="href request.resource_url(request.root, 'login')"
+           i18n:translate="">Login</a>
+
+        <a href="" tal:attributes="href request.resource_url(request.root, 'signup')"
+           i18n:translate="">Join now!</a>
+      </tal:not-logged-in>
+    </div>
+
+  </metal:content>
+
+</html>
Index: ow/tests/test_mail.py
===================================================================
--- ow/tests/test_mail.py	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
+++ ow/tests/test_mail.py	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
@@ -0,0 +1,59 @@
+from unittest.mock import Mock, patch
+
+import pytest
+
+from pyramid.testing import DummyRequest
+from pyramid_mailer.message import Message
+
+from ow.models.root import OpenWorkouts
+from ow.models.user import User
+from ow.utilities import get_verification_token
+from ow.mail import idna_encode_recipients, send_verification_email
+
+
+class TestMail(object):
+
+    @pytest.fixture
+    def root(self):
+        root = OpenWorkouts()
+        user = User(email='user@example.net')
+        user.verification_token = get_verification_token()
+        root.add_user(user)
+        return root
+
+    def test_idna_encode_recipients(self):
+        message = Message(subject='s', recipients=['a@a.com'], body='b')
+        assert message.recipients == ['a@a.com']
+        message = idna_encode_recipients(message)
+        assert message.recipients == ['a@a.com']
+        message.recipients.append(u'a@\xfc.de')
+        assert message.recipients == ['a@a.com', u'a@\xfc.de']
+        message = idna_encode_recipients(message)
+        assert message.recipients == ['a@a.com', 'a@xn--tda.de']
+
+    @patch('ow.mail.get_mailer')
+    @patch('ow.mail.Message')
+    @patch('ow.mail.render')
+    def test_send_verification_email(self, r, m, gm, root):
+        mailer = Mock()
+        gm.return_value = mailer
+        message = Mock()
+        message.recipients = ['user@example.net']
+        m.return_value = message
+        body = Mock()
+        r.return_value = body
+        request = DummyRequest()
+        request.root = root
+        user = root.users[0]
+        send_verification_email(request, user)
+        verify_link = request.resource_url(
+            user, 'verify', user.verification_token)
+        r.assert_called_once_with(
+            'ow:templates/mail_verify_account.pt',
+            {'user': user, 'verify_link': verify_link},
+            request
+        )
+        m.assert_called_once
+        m.call_args_list[0][1]['recipients'] == user.email
+        m.call_args_list[0][1]['body'] == body
+        mailer.send_to_queue.assert_called_once_with(message)
Index: ow/tests/views/test_user.py
===================================================================
--- ow/tests/views/test_user.py	(revision d6da99e1929901626b25b453791629113fafb67c)
+++ ow/tests/views/test_user.py	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
@@ -6,4 +6,5 @@
 from unittest.mock import Mock, patch
 from io import BytesIO
+from uuid import UUID
 
 import pytest
@@ -112,4 +113,62 @@
             })
         return request
+
+    @patch('ow.views.user.remember')
+    def test_verify_already_verified(self, remember, dummy_request, john):
+        john.verified = True
+        response = user_views.verify(john, dummy_request)
+        assert isinstance(response, HTTPFound)
+        assert response.location == dummy_request.resource_url(john)
+        # user was not authenticated
+        assert not remember.called
+        # verified status did not change
+        assert john.verified
+
+    @patch('ow.views.user.remember')
+    def test_verify_no_subpath(self, remember, dummy_request, john):
+        response = user_views.verify(john, dummy_request)
+        # the verify info page is rendered, we don't pass anything to the
+        # rendering context
+        assert response == {}
+        # user was not authenticated
+        assert not remember.called
+        # verified status did not change
+        assert not john.verified
+
+    def test_verify_subpath_not_verified(self, dummy_request, john):
+        dummy_request.subpath = ['not_the_token']
+        response = user_views.verify(john, dummy_request)
+        # the verify info page is rendered, we don't pass anything to the
+        # rendering context
+        assert response == {}
+
+    @patch('ow.views.user.remember')
+    def test_verify_wrong_token(self, remember, dummy_request, john):
+        token = 'some-uuid4'
+        john.verification_token = 'some-other-uuid4'
+        dummy_request.subpath = [token]
+        response = user_views.verify(john, dummy_request)
+        # the verify info page is rendered, we don't pass anything to the
+        # rendering context
+        assert response == {}
+        # user was not authenticated
+        assert not remember.called
+        # verified status did not change, neither did the token
+        assert not john.verified
+        assert john.verification_token == 'some-other-uuid4'
+
+    @patch('ow.views.user.remember')
+    def test_verify_verifying(self, remember, dummy_request, john):
+        token = 'some-uuid4'
+        john.verification_token = token
+        dummy_request.subpath = [token]
+        response = user_views.verify(john, dummy_request)
+        # redirect to user page
+        assert isinstance(response, HTTPFound)
+        assert response.location == dummy_request.resource_url(john)
+        # user was authenticated after verified
+        remember.assert_called_with(dummy_request, str(john.uid))
+        # user has been verified
+        assert john.verified
 
     def test_dashboard_redirect_unauthenticated(self, root):
@@ -369,9 +428,11 @@
         request.POST['email'] = 'john.doe@example.net'
         request.POST['password'] = 'badpassword'
+        # verify the user first
+        request.root.users[0].verified = True
         response = user_views.login(request.root, request)
         assert response['message'] == u'Wrong password'
 
     @patch('ow.views.user.remember')
-    def test_login_post_ok(self, rem, dummy_request, john):
+    def test_login_post_unverified(self, rem, dummy_request, john):
         request = dummy_request
         request.method = 'POST'
@@ -379,4 +440,16 @@
         request.POST['email'] = 'john.doe@example.net'
         request.POST['password'] = 's3cr3t'
+        response = user_views.login(request.root, request)
+        assert response['message'] == u'You have to verify your account first'
+
+    @patch('ow.views.user.remember')
+    def test_login_post_ok(self, rem, dummy_request, john):
+        request = dummy_request
+        request.method = 'POST'
+        request.POST['submit'] = True
+        request.POST['email'] = 'john.doe@example.net'
+        request.POST['password'] = 's3cr3t'
+        # verify the user first
+        john.verified = True
         response = user_views.login(request.root, request)
         assert isinstance(response, HTTPFound)
@@ -620,5 +693,6 @@
         assert response['form'].errorlist() == ''
 
-    def test_signup_post_ok(self, signup_post_request):
+    @patch('ow.views.user.send_verification_email')
+    def test_signup_post_ok(self, sve, signup_post_request):
         request = signup_post_request
         assert 'jack.black@example.net' not in request.root.emails
@@ -629,4 +703,10 @@
         assert 'jack.black@example.net' in request.root.emails
         assert 'JackBlack' in request.root.all_nicknames
+        # user is in "unverified" state
+        user = request.root.get_user_by_email('jack.black@example.net')
+        assert not user.verified
+        assert isinstance(user.verification_token, UUID)
+        # also, we sent an email to that user
+        sve.assert_called_once_with(request, user)
 
     def test_signup_missing_required(self, signup_post_request):
Index: ow/utilities.py
===================================================================
--- ow/utilities.py	(revision d6da99e1929901626b25b453791629113fafb67c)
+++ ow/utilities.py	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
@@ -8,4 +8,5 @@
 from decimal import Decimal
 from shutil import copyfileobj
+from uuid import uuid4
 
 from unidecode import unidecode
@@ -20,4 +21,16 @@
 
 log = logging.getLogger(__name__)
+
+
+def get_verification_token():
+    """
+    Generate a new uuid4 verification token we can give a user for
+    verification purposes.
+    uuid4 is a standard that generates a randomly generated token,
+    optimized for a very low chance of collisions. But even if
+    we had a collision, it wouldn't matter - it's simple some users
+    getting the same token in their verification mail.
+    """
+    return uuid4()
 
 
Index: ow/views/user.py
===================================================================
--- ow/views/user.py	(revision d6da99e1929901626b25b453791629113fafb67c)
+++ ow/views/user.py	(revision 76ebb1baa28f3233b7f78851565788acadbe2f36)
@@ -22,5 +22,6 @@
 from ..models.root import OpenWorkouts
 from ..views.renderers import OWFormRenderer
-from ..utilities import timedelta_to_hms
+from ..utilities import timedelta_to_hms, get_verification_token
+from ..mail import send_verification_email
 
 _ = TranslationStringFactory('OpenWorkouts')
@@ -59,11 +60,14 @@
         user = context.get_user_by_email(email)
         if user:
-            password = request.POST.get('password', None)
-            if password is not None and user.check_password(password):
-                headers = remember(request, str(user.uid))
-                redirect_url = return_to or request.resource_url(user)
-                return HTTPFound(location=redirect_url, headers=headers)
+            if user.verified:
+                password = request.POST.get('password', None)
+                if password is not None and user.check_password(password):
+                    headers = remember(request, str(user.uid))
+                    redirect_url = return_to or request.resource_url(user)
+                    return HTTPFound(location=redirect_url, headers=headers)
+                else:
+                    message = _('Wrong password')
             else:
-                message = _('Wrong password')
+                message = _('You have to verify your account first')
         else:
             message = _('Wrong email address')
@@ -94,5 +98,9 @@
     if 'submit' in request.POST and form.validate():
         user = form.bind(User(), exclude=['password_confirm'])
+        user.verified = False
+        user.verification_token = get_verification_token()
         context.add_user(user)
+        # send a verification link to the user email address
+        send_verification_email(request, user)
         # Send to login
         return HTTPFound(location=request.resource_url(context))
@@ -101,4 +109,30 @@
         'form': OWFormRenderer(form)
     }
+
+
+@view_config(
+    context=User,
+    name="verify",
+    renderer='ow:templates/verify.pt')
+def verify(context, request):
+    redirect_url = request.resource_url(context)
+
+    # user has been verified already, send to dashboard
+    if getattr(context, 'verified', False):
+        return HTTPFound(location=redirect_url)
+
+    # Look for a verification token, then check if we can verify the user with
+    # that token
+    verified = len(request.subpath) > 0
+    token = getattr(context, 'verification_token', False)
+    verified = verified and token and str(token) == request.subpath[0]
+    if verified:
+        # verified, log in automatically and send to the dashboard
+        context.verified = True
+        headers = remember(request, str(context.uid))
+        return HTTPFound(location=redirect_url, headers=headers)
+
+    # if we can not verify the user, show a page with some info about it
+    return {}
 
 
