diff --git a/opentech/apply/dashboard/tables.py b/opentech/apply/dashboard/tables.py
index aea08b27d614ccead18e452f1325bb1f8a79d020..2ef02e44fc5f59f114aeca526788283c690268c9 100644
--- a/opentech/apply/dashboard/tables.py
+++ b/opentech/apply/dashboard/tables.py
@@ -8,5 +8,8 @@ class DashboardTable(tables.Table):
 
     class Meta:
         model = ApplicationSubmission
-        fields = ('title', 'page', 'round', 'submit_time')
+        fields = ('title', 'page', 'round', 'submit_time', 'user')
         template = "dashboard/tables/table.html"
+
+    def render_user(self, value):
+        return value.get_full_name()
diff --git a/opentech/apply/funds/blocks.py b/opentech/apply/funds/blocks.py
index 65caa5c9486429be758e3ea724b938523818dda0..b0cd51c6b93b367785d108aff33610c71bd68d55 100644
--- a/opentech/apply/funds/blocks.py
+++ b/opentech/apply/funds/blocks.py
@@ -148,4 +148,21 @@ class ValueBlock(MustIncludeFieldBlock):
     widget = forms.NumberInput
 
 
+class EmailBlock(MustIncludeFieldBlock):
+    name = 'email'
+    description = 'The applicant email address'
+    widget = forms.EmailInput
+
+    class Meta:
+        icon = 'mail'
+
+
+class FullNameBlock(MustIncludeFieldBlock):
+    name = 'full_name'
+    description = 'Full name'
+
+    class Meta:
+        icon = 'user'
+
+
 REQUIRED_BLOCK_NAMES = [block.name for block in MustIncludeFieldBlock.__subclasses__()]
diff --git a/opentech/apply/funds/migrations/00014_add_meta_names.py b/opentech/apply/funds/migrations/0014_add_meta_names.py
similarity index 100%
rename from opentech/apply/funds/migrations/00014_add_meta_names.py
rename to opentech/apply/funds/migrations/0014_add_meta_names.py
diff --git a/opentech/apply/funds/migrations/0015_link_user_to_application.py b/opentech/apply/funds/migrations/0015_link_user_to_application.py
new file mode 100644
index 0000000000000000000000000000000000000000..b71b0ab26574fa9b2200e0192f104cdfadbdc13f
--- /dev/null
+++ b/opentech/apply/funds/migrations/0015_link_user_to_application.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.8 on 2018-02-01 16:45
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import opentech.apply.categories.blocks
+import wagtail.wagtailcore.blocks
+import wagtail.wagtailcore.blocks.static_block
+import wagtail.wagtailcore.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('funds', '0014_add_meta_names'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='applicationsubmission',
+            name='user',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AddField(
+            model_name='fundtype',
+            name='confirmation_text_extra',
+            field=models.TextField(blank=True, help_text='Additional text for the application confirmation message.'),
+        ),
+        migrations.AddField(
+            model_name='fundtype',
+            name='from_address',
+            field=models.CharField(blank=True, max_length=255, verbose_name='from address'),
+        ),
+        migrations.AddField(
+            model_name='fundtype',
+            name='subject',
+            field=models.CharField(blank=True, max_length=255, verbose_name='subject'),
+        ),
+        migrations.AddField(
+            model_name='fundtype',
+            name='to_address',
+            field=models.CharField(blank=True, help_text='Optional - form submissions will be emailed to these addresses. Separate multiple addresses by comma.', max_length=255, verbose_name='to address'),
+        ),
+        migrations.AddField(
+            model_name='labtype',
+            name='confirmation_text_extra',
+            field=models.TextField(blank=True, help_text='Additional text for the application confirmation message.'),
+        ),
+        migrations.AddField(
+            model_name='labtype',
+            name='from_address',
+            field=models.CharField(blank=True, max_length=255, verbose_name='from address'),
+        ),
+        migrations.AddField(
+            model_name='labtype',
+            name='subject',
+            field=models.CharField(blank=True, max_length=255, verbose_name='subject'),
+        ),
+        migrations.AddField(
+            model_name='labtype',
+            name='to_address',
+            field=models.CharField(blank=True, help_text='Optional - form submissions will be emailed to these addresses. Separate multiple addresses by comma.', max_length=255, verbose_name='to address'),
+        ),
+        migrations.AlterField(
+            model_name='applicationform',
+            name='form_fields',
+            field=wagtail.wagtailcore.fields.StreamField((('text_markup', wagtail.wagtailcore.blocks.RichTextBlock(group='Other', label='Paragraph')), ('char', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('format', wagtail.wagtailcore.blocks.ChoiceBlock(choices=[('email', 'Email'), ('url', 'URL')], label='Format', required=False)), ('default_value', wagtail.wagtailcore.blocks.CharBlock(label='Default value', required=False))), group='Fields')), ('text', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.wagtailcore.blocks.TextBlock(label='Default value', required=False))), group='Fields')), ('number', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.wagtailcore.blocks.CharBlock(label='Default value', required=False))), group='Fields')), ('checkbox', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('default_value', wagtail.wagtailcore.blocks.BooleanBlock(required=False))), group='Fields')), ('radios', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.wagtailcore.blocks.ListBlock(wagtail.wagtailcore.blocks.CharBlock(label='Choice')))), group='Fields')), ('dropdown', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.wagtailcore.blocks.ListBlock(wagtail.wagtailcore.blocks.CharBlock(label='Choice')))), group='Fields')), ('checkboxes', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('checkboxes', wagtail.wagtailcore.blocks.ListBlock(wagtail.wagtailcore.blocks.CharBlock(label='Checkbox')))), group='Fields')), ('date', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.wagtailcore.blocks.DateBlock(required=False))), group='Fields')), ('time', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.wagtailcore.blocks.TimeBlock(required=False))), group='Fields')), ('datetime', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.wagtailcore.blocks.DateTimeBlock(required=False))), group='Fields')), ('image', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False))), group='Fields')), ('file', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False))), group='Fields')), ('rich_text', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.wagtailcore.blocks.TextBlock(label='Default value', required=False))), group='Fields')), ('category', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(help_text='Leave blank to use the default Category label', label='Label', required=False)), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Leave blank to use the default Category help text', required=False)), ('required', wagtail.wagtailcore.blocks.BooleanBlock(label='Required', required=False)), ('category', opentech.apply.categories.blocks.ModelChooserBlock('categories.Category')), ('multi', wagtail.wagtailcore.blocks.BooleanBlock(label='Multi select', required=False))), group='Custom')), ('title', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('info', wagtail.wagtailcore.blocks.static_block.StaticBlock())), group='Required')), ('value', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('info', wagtail.wagtailcore.blocks.static_block.StaticBlock())), group='Required')), ('email', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('info', wagtail.wagtailcore.blocks.static_block.StaticBlock())), group='Required')), ('full_name', wagtail.wagtailcore.blocks.StructBlock((('field_label', wagtail.wagtailcore.blocks.CharBlock(label='Label')), ('help_text', wagtail.wagtailcore.blocks.TextBlock(label='Help text', required=False)), ('info', wagtail.wagtailcore.blocks.static_block.StaticBlock())), group='Required')))),
+        ),
+    ]
diff --git a/opentech/apply/funds/models.py b/opentech/apply/funds/models.py
index f0b7c597932f2d8c5bf58ad04732221b75be08f6..7b368cdf49e6e121c6b99a5c7cc7c34fb1444630 100644
--- a/opentech/apply/funds/models.py
+++ b/opentech/apply/funds/models.py
@@ -1,5 +1,7 @@
 from datetime import date
 
+from django.conf import settings
+from django.contrib.auth import get_user_model
 from django.contrib.postgres.fields import JSONField
 from django.core.exceptions import ValidationError
 from django.core.serializers.json import DjangoJSONEncoder
@@ -7,6 +9,7 @@ from django.db import models
 from django.db.models import Q
 from django.db.models.expressions import RawSQL, OrderBy
 from django.http import Http404
+from django.template.loader import render_to_string
 from django.urls import reverse
 from django.utils.text import mark_safe
 from django.utils.translation import ugettext_lazy as _
@@ -14,14 +17,18 @@ from django.utils.translation import ugettext_lazy as _
 from modelcluster.fields import ParentalKey
 from wagtail.wagtailadmin.edit_handlers import (
     FieldPanel,
-    InlinePanel,
     FieldRowPanel,
+    InlinePanel,
     MultiFieldPanel,
+    ObjectList,
     StreamFieldPanel,
+    TabbedInterface
 )
+
+from wagtail.wagtailadmin.utils import send_mail
 from wagtail.wagtailcore.fields import StreamField
-from wagtail.wagtailcore.models import Orderable
-from wagtail.wagtailforms.models import AbstractFormSubmission
+from wagtail.wagtailcore.models import Orderable, Page
+from wagtail.wagtailforms.models import AbstractEmailForm, AbstractFormSubmission
 
 from opentech.apply.stream_forms.models import AbstractStreamForm
 
@@ -55,16 +62,29 @@ class SubmittableStreamForm(AbstractStreamForm):
                 response = cleaned_data.pop(field.id)
                 cleaned_data[field.block.name] = response
 
+        if form.user.is_authenticated():
+            user = form.user
+            cleaned_data['email'] = user.email
+            cleaned_data['full_name'] = user.get_full_name()
+        else:
+            User = get_user_model()
+            email = cleaned_data.get('email')
+            full_name = cleaned_data.get('full_name')
+            user, _ = User.objects.get_or_create_and_notify(
+                email=email,
+                defaults={'full_name': full_name}
+            )
+
         return self.get_submission_class().objects.create(
             form_data=cleaned_data,
-            **self.get_submit_meta_data(),
+            **self.get_submit_meta_data(user=user),
         )
 
     def get_submit_meta_data(self, **kwargs):
         return kwargs
 
 
-class DefinableWorkflowStreamForm(AbstractStreamForm):
+class DefinableWorkflowStreamForm(AbstractEmailForm, AbstractStreamForm):
     class Meta:
         abstract = True
 
@@ -76,6 +96,7 @@ class DefinableWorkflowStreamForm(AbstractStreamForm):
     }
 
     workflow = models.CharField(choices=WORKFLOWS.items(), max_length=100, default='single')
+    confirmation_text_extra = models.TextField(blank=True, help_text="Additional text for the application confirmation message.")
 
     def get_defined_fields(self):
         # Only return the first form, will need updating for when working with 2 stage WF
@@ -85,11 +106,51 @@ class DefinableWorkflowStreamForm(AbstractStreamForm):
     def workflow_class(self):
         return WORKFLOW_CLASS[self.get_workflow_display()]
 
+    def process_form_submission(self, form):
+        submission = super().process_form_submission(form)
+        self.send_mail(form)
+        return submission
+
+    def send_mail(self, form):
+        data = form.cleaned_data
+        email = data.get('email')
+        context = {
+            'name': data.get('full_name'),
+            'email': email,
+            'project_name': data.get('title'),
+            'extra_text': self.confirmation_text_extra,
+            'fund_type': self.title,
+        }
+
+        subject = self.subject if self.subject else 'Thank You for Your submission to Open Technology Fund'
+        send_mail(subject, render_to_string('funds/email/confirmation.txt', context), (email,), self.from_address, )
+
     content_panels = AbstractStreamForm.content_panels + [
         FieldPanel('workflow'),
         InlinePanel('forms', label="Forms"),
     ]
 
+    email_confirmation_panels = [
+        MultiFieldPanel(
+            [
+                FieldRowPanel([
+                    FieldPanel('from_address', classname="col6"),
+                    FieldPanel('to_address', classname="col6"),
+                ]),
+                FieldPanel('subject'),
+                FieldPanel('confirmation_text_extra'),
+            ],
+            heading="Confirmation email",
+        )
+    ]
+
+    edit_handler = TabbedInterface([
+        ObjectList(content_panels, heading='Content'),
+        ObjectList(email_confirmation_panels, heading='Confirmation email'),
+        ObjectList(Page.promote_panels, heading='Promote'),
+        ObjectList(Page.settings_panels, heading='Settings', classname="settings"),
+    ])
+
 
 class FundType(DefinableWorkflowStreamForm):
     class Meta:
@@ -168,6 +229,11 @@ class Round(SubmittableStreamForm):
             **kwargs,
         )
 
+    def process_form_submission(self, form):
+        submission = super().process_form_submission(form)
+        self.get_parent().specific.send_mail(form)
+        return submission
+
     def get_defined_fields(self):
         # Only return the first form, will need updating for when working with 2 stage WF
         return self.get_parent().specific.forms.all()[0].fields
@@ -235,6 +301,10 @@ class LabType(DefinableWorkflowStreamForm, SubmittableStreamForm):  # type: igno
     parent_page_types = ['apply_home.ApplyHomePage']
     subpage_types = []  # type: ignore
 
+    def get_defined_fields(self):
+        # Only return the first form, will need updating for when working with 2 stage WF
+        return self.specific.forms.all()[0].fields
+
     def get_submit_meta_data(self, **kwargs):
         return super().get_submit_meta_data(
             page=self,
@@ -275,6 +345,7 @@ class JSONOrderable(models.QuerySet):
 class ApplicationSubmission(AbstractFormSubmission):
     form_data = JSONField(encoder=DjangoJSONEncoder)
     round = models.ForeignKey('wagtailcore.Page', on_delete=models.CASCADE, related_name='submissions', null=True)
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
 
     objects = JSONOrderable.as_manager()
 
diff --git a/opentech/apply/funds/templates/funds/email/confirmation.txt b/opentech/apply/funds/templates/funds/email/confirmation.txt
new file mode 100644
index 0000000000000000000000000000000000000000..6756377e3ca2e9ed4f5726981776b543484a050e
--- /dev/null
+++ b/opentech/apply/funds/templates/funds/email/confirmation.txt
@@ -0,0 +1,20 @@
+Dear {{ name|default:"applicant" }},
+
+We appreciate your {{ fund_type }} application submission to the Open Technology Fund. We will review and reply to your
+submission as quickly as possible. Our reply will have the next steps for your {{ fund_type }} application.
+You can find more information about our support options, review process and selection criteria on our website.
+
+{% if extra_text %}{{ extra_text }}{% endif %}
+
+If you have any questions, please email us at info@opentechfund.org.
+
+{% if project_name %}Project name: {{ project_name }}{% endif %}
+{% if name %}Contact name: {{ name }}{% endif %}
+{% if email %}Contact email: {{ email }}{% endif %}
+
+Thanks again,
+The OTF Team
+
+--
+Open Technology Fund
+https://www.opentech.fund/
diff --git a/opentech/apply/funds/tests/factories/__init__.py b/opentech/apply/funds/tests/factories/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c9d2b8ebadf12898359444c6059e84c4383a94cc
--- /dev/null
+++ b/opentech/apply/funds/tests/factories/__init__.py
@@ -0,0 +1,9 @@
+from . import models
+from .models import * # noqa
+from . import blocks
+from .blocks import *  # noqa
+
+__all__ = []
+
+__all__.extend(blocks.__all__)
+__all__.extend(models.__all__)
diff --git a/opentech/apply/funds/tests/factories/blocks.py b/opentech/apply/funds/tests/factories/blocks.py
new file mode 100644
index 0000000000000000000000000000000000000000..de04acc9acf1ea0a87f318f0481023bf121c5049
--- /dev/null
+++ b/opentech/apply/funds/tests/factories/blocks.py
@@ -0,0 +1,22 @@
+import wagtail_factories
+
+from opentech.apply.stream_forms.blocks import FormFieldBlock
+from opentech.apply.funds import blocks
+
+
+__all__ = ['FormFieldBlock', 'FullNameBlockFactory', 'EmailBlockFactory']
+
+
+class FormFieldBlockFactory(wagtail_factories.StructBlockFactory):
+    class Meta:
+        model = FormFieldBlock
+
+
+class EmailBlockFactory(FormFieldBlockFactory):
+    class Meta:
+        model = blocks.EmailBlock
+
+
+class FullNameBlockFactory(FormFieldBlockFactory):
+    class Meta:
+        model = blocks.FullNameBlock
diff --git a/opentech/apply/funds/tests/factories.py b/opentech/apply/funds/tests/factories/models.py
similarity index 80%
rename from opentech/apply/funds/tests/factories.py
rename to opentech/apply/funds/tests/factories/models.py
index aa37d55a8e2ad6bfe00e33916aa381eedaac16d4..3850da9b1bffa3dffa3af600011bbc51788c6492 100644
--- a/opentech/apply/funds/tests/factories.py
+++ b/opentech/apply/funds/tests/factories/models.py
@@ -4,9 +4,32 @@ from django.forms import Form
 import factory
 import wagtail_factories
 
-from opentech.apply.funds.models import ApplicationForm, FundType, FundForm, Round
+from opentech.apply.funds.models import (
+    ApplicationForm,
+    FundType,
+    FundForm,
+    LabForm,
+    LabType,
+    Round,
+)
 from opentech.apply.funds.workflow import Action, Phase, Stage, Workflow
 
+from . import blocks
+
+
+__all__ = [
+    'ActionFactory',
+    'PhaseFactory',
+    'StageFactory',
+    'WorkflowFactory',
+    'FundTypeFactory',
+    'FundFormFactory',
+    'ApplicationFormFactory',
+    'RoundFactory',
+    'LabFactory',
+    'LabFormFactory',
+]
+
 
 class ListSubFactory(factory.SubFactory):
     def __init__(self, *args, count=0, **kwargs):
@@ -137,6 +160,10 @@ class ApplicationFormFactory(factory.DjangoModelFactory):
         model = ApplicationForm
 
     name = factory.Faker('word')
+    form_fields = wagtail_factories.StreamFieldFactory({
+        'email': blocks.EmailBlockFactory,
+        'full_name': blocks.FullNameBlockFactory,
+    })
 
 
 class RoundFactory(wagtail_factories.PageFactory):
@@ -146,3 +173,22 @@ class RoundFactory(wagtail_factories.PageFactory):
     title = factory.Sequence('Round {}'.format)
     start_date = factory.LazyFunction(datetime.date.today)
     end_date = factory.LazyFunction(lambda: datetime.date.today() + datetime.timedelta(days=7))
+
+
+class LabFactory(wagtail_factories.PageFactory):
+    class Meta:
+        model = LabType
+
+    class Params:
+        workflow_stages = 1
+        number_forms = 1
+
+    # Will need to update how the stages are identified as Fund Page changes
+    workflow = factory.LazyAttribute(lambda o: list(FundType.WORKFLOWS.keys())[o.workflow_stages - 1])
+
+
+class LabFormFactory(factory.DjangoModelFactory):
+    class Meta:
+        model = LabForm
+    lab = factory.SubFactory(LabFactory, parent=None)
+    form = factory.SubFactory('opentech.apply.tests.factories.ApplicationFormFactory')
diff --git a/opentech/apply/funds/tests/test_models.py b/opentech/apply/funds/tests/test_models.py
index 0c6fcc20cfecf8f907c63b447bd295cfab81275c..fe534f3818c9d0491d04ffaaf56cd521f4b1c522 100644
--- a/opentech/apply/funds/tests/test_models.py
+++ b/opentech/apply/funds/tests/test_models.py
@@ -1,11 +1,24 @@
 from datetime import date, timedelta
 
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import AnonymousUser
+from django.core import mail
 from django.core.exceptions import ValidationError
-from django.test import TestCase
+from django.test import RequestFactory, TestCase
 
+from wagtail.wagtailcore.models import Site
+
+from opentech.apply.funds.models import ApplicationSubmission
 from opentech.apply.funds.workflow import SingleStage
 
-from .factories import FundTypeFactory, RoundFactory
+from .factories import (
+    ApplicationFormFactory,
+    FundFormFactory,
+    FundTypeFactory,
+    LabFactory,
+    LabFormFactory,
+    RoundFactory,
+)
 
 
 def days_from_today(days):
@@ -124,3 +137,113 @@ class TestRoundModel(TestCase):
 
         with self.assertRaises(ValidationError):
             new_round.clean()
+
+
+class TestFormSubmission(TestCase):
+    def setUp(self):
+        self.site = Site.objects.first()
+        self.User = get_user_model()
+
+        self.email = 'test@test.com'
+        self.name = 'My Name'
+
+        self.request_factory = RequestFactory()
+        # set up application form with minimal requirement for creating user
+        application_form = {
+            'form_fields__0__email__': '',
+            'form_fields__1__full_name__': '',
+        }
+        form = ApplicationFormFactory(**application_form)
+        fund = FundTypeFactory()
+        FundFormFactory(fund=fund, form=form)
+        self.round_page = RoundFactory(parent=fund)
+        self.lab_page = LabFactory()
+        LabFormFactory(lab=self.lab_page, form=form)
+
+    def submit_form(self, page=None, email=None, name=None, user=AnonymousUser()):
+        if email is None:
+            email = self.email
+        if name is None:
+            name = self.name
+
+        page = page or self.round_page
+        fields = page.get_form_fields()
+        data = {k: v for k, v in zip(fields, [email, name])}
+
+        request = self.request_factory.post('', data)
+        request.user = user
+        request.site = self.site
+
+        try:
+            return page.get_parent().serve(request)
+        except AttributeError:
+            return page.serve(request)
+
+    def test_can_submit_if_new(self):
+        self.submit_form()
+
+        self.assertEqual(self.User.objects.count(), 1)
+        new_user = self.User.objects.get(email=self.email)
+        self.assertEqual(new_user.get_full_name(), self.name)
+
+        self.assertEqual(ApplicationSubmission.objects.count(), 1)
+        self.assertEqual(ApplicationSubmission.objects.first().user, new_user)
+
+    def test_associated_if_not_new(self):
+        self.submit_form()
+        self.submit_form()
+
+        self.assertEqual(self.User.objects.count(), 1)
+
+        user = self.User.objects.get(email=self.email)
+        self.assertEqual(ApplicationSubmission.objects.count(), 2)
+        self.assertEqual(ApplicationSubmission.objects.first().user, user)
+
+    def test_associated_if_another_user_exists(self):
+        self.submit_form()
+        # Someone else submits a form
+        self.submit_form(email='another@email.com')
+
+        self.assertEqual(self.User.objects.count(), 2)
+
+        first_user, second_user = self.User.objects.all()
+        self.assertEqual(ApplicationSubmission.objects.count(), 2)
+        self.assertEqual(ApplicationSubmission.objects.first().user, first_user)
+        self.assertEqual(ApplicationSubmission.objects.last().user, second_user)
+
+    def test_associated_if_logged_in(self):
+        user, _ = self.User.objects.get_or_create(email=self.email, defaults={'full_name': self.name})
+
+        self.assertEqual(self.User.objects.count(), 1)
+
+        self.submit_form(email=self.email, name=self.name, user=user)
+
+        self.assertEqual(self.User.objects.count(), 1)
+
+        self.assertEqual(ApplicationSubmission.objects.count(), 1)
+        self.assertEqual(ApplicationSubmission.objects.first().user, user)
+
+    # This will need to be updated when we hide user information contextually
+    def test_errors_if_blank_user_data_even_if_logged_in(self):
+        user, _ = self.User.objects.get_or_create(email=self.email, defaults={'full_name': self.name})
+
+        self.assertEqual(self.User.objects.count(), 1)
+
+        response = self.submit_form(email='', name='', user=user)
+        self.assertContains(response, 'This field is required')
+
+        self.assertEqual(self.User.objects.count(), 1)
+
+        self.assertEqual(ApplicationSubmission.objects.count(), 0)
+
+    def test_email_sent_to_user_on_submission_fund(self):
+        self.submit_form()
+        # "Thank you for your submission" and "Account Creation"
+        self.assertEqual(len(mail.outbox), 2)
+        self.assertEqual(mail.outbox[0].to[0], self.email)
+
+    def test_email_sent_to_user_on_submission_lab(self):
+        self.submit_form(page=self.lab_page)
+        # "Thank you for your submission" and "Account Creation"
+        self.assertEqual(len(mail.outbox), 2)
+        self.assertEqual(mail.outbox[0].to[0], self.email)
diff --git a/opentech/apply/users/migrations/0003_make_email_username.py b/opentech/apply/users/migrations/0003_make_email_username.py
new file mode 100644
index 0000000000000000000000000000000000000000..117321f644af34da3ff44901d645af3c8795a9d6
--- /dev/null
+++ b/opentech/apply/users/migrations/0003_make_email_username.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.8 on 2018-02-05 10:52
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+import opentech.apply.users.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0002_initial_data'),
+    ]
+
+    operations = [
+        migrations.AlterModelManagers(
+            name='user',
+            managers=[
+                ('objects', opentech.apply.users.models.UserManager()),
+            ],
+        ),
+        migrations.AlterField(
+            model_name='user',
+            name='email',
+            field=models.EmailField(max_length=254, unique=True, verbose_name='email address'),
+        ),
+        migrations.RemoveField(
+            model_name='user',
+            name='username',
+        ),
+    ]
diff --git a/opentech/apply/users/models.py b/opentech/apply/users/models.py
index 3d305253bf8157dcb4249315ffef41fa60655ab5..67b022049b54c88f23f7c9a040e6ec3b04895c90 100644
--- a/opentech/apply/users/models.py
+++ b/opentech/apply/users/models.py
@@ -1,5 +1,71 @@
-from django.contrib.auth.models import AbstractUser
+from django.db import models
+from django.contrib.auth.models import AbstractUser, BaseUserManager
+from django.utils.translation import gettext_lazy as _
+
+from .utils import send_activation_email
+
+
+def convert_full_name_to_parts(defaults):
+    full_name = defaults.pop('full_name', ' ')
+    first_name, *last_name = full_name.split(' ')
+    if first_name:
+        defaults.update(first_name=first_name)
+    if last_name:
+        defaults.update(last_name=' '.join(last_name))
+    return defaults
+
+
+class UserManager(BaseUserManager):
+    use_in_migrations = True
+
+    def _create_user(self, email, password, **extra_fields):
+        """
+        Creates and saves a User with the given username, email and password.
+        """
+        if not email:
+            raise ValueError('The given email must be set')
+        email = self.normalize_email(email)
+        extra_fields = convert_full_name_to_parts(extra_fields)
+        user = self.model(email=email, **extra_fields)
+        user.set_password(password)
+        user.save(using=self._db)
+        return user
+
+    def create_user(self, email, password=None, **extra_fields):
+        extra_fields.setdefault('is_staff', False)
+        extra_fields.setdefault('is_superuser', False)
+        return self._create_user(email, password, **extra_fields)
+
+    def create_superuser(self, email, password, **extra_fields):
+        extra_fields.setdefault('is_staff', True)
+        extra_fields.setdefault('is_superuser', True)
+
+        if extra_fields.get('is_staff') is not True:
+            raise ValueError('Superuser must have is_staff=True.')
+        if extra_fields.get('is_superuser') is not True:
+            raise ValueError('Superuser must have is_superuser=True.')
+
+        return self._create_user(email, password, **extra_fields)
+
+    def get_or_create(self, defaults, **kwargs):
+        # Allow passing of 'full_name' but replace it with actual database fields
+        defaults = convert_full_name_to_parts(defaults)
+        return super().get_or_create(defaults=defaults, **kwargs)
+
+    def get_or_create_and_notify(self, defaults=dict(), **kwargs):
+        defaults.update(is_active=False)
+        user, created = self.get_or_create(defaults=defaults, **kwargs)
+        if created:
+            send_activation_email(user)
+        return user, created
 
 
 class User(AbstractUser):
-    pass
+    USERNAME_FIELD = 'email'
+    email = models.EmailField(_('email address'), unique=True)
+    REQUIRED_FIELDS = []
+
+    # Remove the username field which is no longer used
+    username = None
+
+    objects = UserManager()
diff --git a/opentech/apply/users/templates/users/activation/email.txt b/opentech/apply/users/templates/users/activation/email.txt
new file mode 100644
index 0000000000000000000000000000000000000000..3f01c1865629f7bbb1fa18737d892c97f93de5b3
--- /dev/null
+++ b/opentech/apply/users/templates/users/activation/email.txt
@@ -0,0 +1,20 @@
+{% load wagtailadmin_tags %}{% base_url_setting as base_url %}
+Dear {{ name|default:username }},
+
+An account on Open Technology Fund has been created. Activate your account by clicking this link or copying and pasting it to your browser:
+
+{% if base_url %}{{ base_url }}{% else %}{{ protocol }}://{{ domain }}{% endif %}{% url 'users:activate' uidb64=uid token=token %}
+
+This link can be used once to log in and will lead you to a page where you can set your password.
+
+After setting your password, you will be able to log in at {% if base_url %}{{ base_url }}{% else %}{{ protocol }}://{{ domain }}{% endif %} in the future using:
+
+username: {{ username }}
+password: Your chosen password
+
+Thanks,
+The OTF Team
+
+--
+Open Technology Fund
+https://www.opentech.fund/
diff --git a/opentech/apply/users/templates/users/activation/email_subject.txt b/opentech/apply/users/templates/users/activation/email_subject.txt
new file mode 100644
index 0000000000000000000000000000000000000000..1f1a8daafc3057f4227badf39bc239a6c131b73a
--- /dev/null
+++ b/opentech/apply/users/templates/users/activation/email_subject.txt
@@ -0,0 +1 @@
+Account details for {{ username }} at Open Technology Fund
diff --git a/opentech/apply/users/templates/users/activation/invalid.html b/opentech/apply/users/templates/users/activation/invalid.html
new file mode 100644
index 0000000000000000000000000000000000000000..df12380a7ae5089993432b54a7a3201050afc583
--- /dev/null
+++ b/opentech/apply/users/templates/users/activation/invalid.html
@@ -0,0 +1,7 @@
+{% extends 'base.html' %}
+
+{% block title %}Invalid activation{% endblock %}
+
+{% block content %}
+    <h2>Invalid activation URL</h2>
+{% endblock %}
diff --git a/opentech/apply/users/templates/users/change_password.html b/opentech/apply/users/templates/users/change_password.html
index 26799c69da341f713f390e9eb7139740fbbb8c1a..178f25355ec310ee23a30bab009fc1665c9e60ec 100644
--- a/opentech/apply/users/templates/users/change_password.html
+++ b/opentech/apply/users/templates/users/change_password.html
@@ -25,7 +25,7 @@
         </div>
     {% endif %}
 
-    <form action="{% url 'users:password_change' %}" method="POST" novalidate>
+    <form action="" method="POST" novalidate>
         {% csrf_token %}
 
         {% for field in form %}
diff --git a/opentech/apply/users/tests/test_oauth_access.py b/opentech/apply/users/tests/test_oauth_access.py
index bfc57741741767c454e6231c1060e7fb352973d8..92d3f2ae25fa42123fb5d1530574e3604a1c2d72 100644
--- a/opentech/apply/users/tests/test_oauth_access.py
+++ b/opentech/apply/users/tests/test_oauth_access.py
@@ -5,6 +5,13 @@ from django.urls import reverse
 
 
 class TestOAuthAccess(TestCase):
+    def login(self):
+        email = 'test@email.com'
+        password = 'password'
+        user = get_user_model().objects.create_user(email=email, password=password)
+        logged_in = self.client.login(email=email, password=password)
+        self.assertTrue(logged_in)
+        return user
 
     def test_oauth_page_requires_login(self):
         """
@@ -48,11 +55,3 @@ class TestOAuthAccess(TestCase):
         self.assertNotContains(response, 'Disconnect Google OAuth')
 
         self.assertTemplateUsed(response, 'users/oauth.html')
-
-    def login(self):
-        user = get_user_model().objects.create_user(username='test', email='test@email.com', password='password')
-        self.assertTrue(
-            self.client.login(username='test', password='password')
-        )
-
-        return user
diff --git a/opentech/apply/users/urls.py b/opentech/apply/users/urls.py
index 9ade202f71d16202266ca7d6085c4703b2f8fd92..877b4113b84a781a744b5f55215bdcd75159f982 100644
--- a/opentech/apply/users/urls.py
+++ b/opentech/apply/users/urls.py
@@ -2,7 +2,7 @@ from django.conf.urls import url
 from django.contrib.auth import views as auth_views
 from django.urls import reverse_lazy
 
-from opentech.apply.users.views import account, oauth
+from opentech.apply.users.views import account, oauth, ActivationView, create_password
 
 urlpatterns = [
     url(r'^$', account, name='account'),
@@ -54,5 +54,11 @@ urlpatterns = [
         auth_views.PasswordResetCompleteView.as_view(template_name='users/password_reset/complete.html'),
         name='password_reset_complete'
     ),
+    url(
+        r'^activate/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
+        ActivationView.as_view(),
+        name='activate'
+    ),
+    url(r'^activate/password/', create_password, name="activate_password"),
     url(r'^oauth$', oauth, name='oauth'),
 ]
diff --git a/opentech/apply/users/utils.py b/opentech/apply/users/utils.py
index 0e290b3203a99a774513b2f2dc5bd9134b4b3cfd..7f4ae6d4c233f1ed4b8b142dcc4c3f73bb3a5074 100644
--- a/opentech/apply/users/utils.py
+++ b/opentech/apply/users/utils.py
@@ -1,4 +1,8 @@
 from django.conf import settings
+from django.contrib.auth.tokens import PasswordResetTokenGenerator
+from django.template.loader import render_to_string
+from django.utils.encoding import force_bytes
+from django.utils.http import urlsafe_base64_encode
 
 
 def can_use_oauth_check(user):
@@ -14,3 +18,24 @@ def can_use_oauth_check(user):
         # Anonymous user or setting not defined
         pass
     return False
+
+
+def send_activation_email(user):
+    """
+    Send the activation email. The activation key is the username,
+    signed using TimestampSigner.
+    """
+    token_generator = PasswordResetTokenGenerator()
+    context = {
+        'user': user,
+        'name': user.get_full_name(),
+        'username': user.get_username(),
+        'uid': urlsafe_base64_encode(force_bytes(user.pk)),
+        'token': token_generator.make_token(user),
+    }
+
+    subject = render_to_string('users/activation/email_subject.txt', context)
+    # Force subject to a single line to avoid header-injection issues.
+    subject = ''.join(subject.splitlines())
+    message = render_to_string('users/activation/email.txt', context)
+    user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
diff --git a/opentech/apply/users/views.py b/opentech/apply/users/views.py
index c9bad14dfb283aa4c1c8caa5f2e769cf20708025..6ad1534218120026b627d9df57ef0e5b293fc4ea 100644
--- a/opentech/apply/users/views.py
+++ b/opentech/apply/users/views.py
@@ -1,7 +1,14 @@
+from django.contrib import messages
+from django.contrib.auth import get_user_model, login, update_session_auth_hash
 from django.contrib.auth.decorators import login_required
-from django.shortcuts import render
+from django.contrib.auth.forms import AdminPasswordChangeForm
+from django.contrib.auth.tokens import PasswordResetTokenGenerator
+from django.shortcuts import redirect, render
 from django.template.response import TemplateResponse
 from django.urls import reverse_lazy
+from django.utils.encoding import force_text
+from django.utils.http import urlsafe_base64_decode
+from django.views.generic.base import TemplateView
 
 from wagtail.wagtailadmin.views.account import password_management_enabled
 
@@ -10,7 +17,7 @@ from .decorators import require_oauth_whitelist
 
 @login_required(login_url=reverse_lazy('users:login'))
 def account(request):
-    "Account page placeholder view"
+    """Account page placeholder view"""
 
     return render(request, 'users/account.html', {
         'show_change_password': password_management_enabled() and request.user.has_usable_password(),
@@ -20,8 +27,68 @@ def account(request):
 @login_required(login_url=reverse_lazy('users:login'))
 @require_oauth_whitelist
 def oauth(request):
+    """Generic, empty view for the OAuth associations."""
+
+    return TemplateResponse(request, 'users/oauth.html', {})
+
+
+class ActivationView(TemplateView):
+    def get(self, request, *args, **kwargs):
+        user = self.get_user(kwargs.get('uidb64'))
+
+        if self.valid(user, kwargs.get('token')):
+            user.is_active = True
+            user.save()
+
+            user.backend = 'django.contrib.auth.backends.ModelBackend'
+            login(request, user)
+            return redirect('users:activate_password')
+
+        return render(request, 'users/activation/invalid.html')
+
+    def valid(self, user, token):
+        """
+        Verify that the activation token is valid and within the
+        permitted activation time window.
+        """
+
+        token_generator = PasswordResetTokenGenerator()
+        return user is not None and token_generator.check_token(user, token)
+
+    def get_user(self, uidb64):
+        """
+        Given the verified uid, look up and return the
+        corresponding user account if it exists, or ``None`` if it
+        doesn't.
+        """
+        User = get_user_model()
+
+        try:
+            user = User.objects.get(**{
+                'pk': force_text(urlsafe_base64_decode(uidb64)),
+                'is_active': False
+            })
+            return user
+        except (TypeError, ValueError, OverflowError, User.DoesNotExist):
+            return None
+
+
+def create_password(request):
     """
-    Generic, empty view for the OAuth associations.
+    A custom view for the admin password change form used for account activation.
     """
 
-    return TemplateResponse(request, 'users/oauth.html', {})
+    if request.method == 'POST':
+        form = AdminPasswordChangeForm(request.user, request.POST)
+        if form.is_valid():
+            user = form.save()
+            update_session_auth_hash(request, user)  # Important!
+            messages.success(request, 'Your password was successfully updated!')
+            return redirect('users:account')
+        else:
+            messages.error(request, 'Please correct the errors below.')
+    else:
+        form = AdminPasswordChangeForm(request.user)
+    return render(request, 'users/change_password.html', {
+        'form': form
+    })
diff --git a/opentech/settings/base.py b/opentech/settings/base.py
index 299bbb8948dae30f06bc9db15cd8fb7fc37e93da..c4b14f5094d3e1845b9c434d1113cd8035c6c339 100644
--- a/opentech/settings/base.py
+++ b/opentech/settings/base.py
@@ -200,6 +200,8 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
 MEDIA_URL = '/media/'
 
 AUTH_USER_MODEL = 'users.User'
+
+
 # TODO populate me with the dashboard URL when ready
 LOGIN_URL = 'users:login'
 LOGIN_REDIRECT_URL = '/'
diff --git a/requirements.txt b/requirements.txt
index 1921779a9b06174f743dc5f66270d8a6abec7506..dd5f1a023bee983b59baf9266838c33505d375a4 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,7 +11,8 @@ uwsgidecorators==1.1.0
 
 mypy==0.550
 factory_boy==2.9.2
-wagtail_factories==0.3.0
+# wagtail_factories - waiting on merge and release form master branch
+git+git://github.com/todd-dembrey/wagtail-factories.git#egg=wagtail_factories
 
 flake8