Repository: saleor/saleor
Pull request: Account confirmation by email when creating new account
I want to merge this change because it resolves #1527 
Cannot be merged until appropriate frontend view is ready (confirmation part that triggers the mutation)
Should also be merged with storefront mutation switch (which can be merged even sooner)
https://github.com/mirumee/saleor-storefront/pull/549

<!-- Please mention all relevant issue numbers. -->


<!-- If your changes affect the UI, providing "before" and "after" screenshots will
greatly reduce the amount of work needed to review your work. -->

### Pull Request Checklist

<!-- Please keep this section. It will make maintainer's life easier. -->

1. [x] Privileged views and APIs are guarded by proper permission checks.
1. [x] All visible strings are translated with proper context.
1. [x] All data-formatting is locale-aware (dates, numbers, and so on).
1. [x] Database queries are optimized and the number of queries is constant.
1. [x] Database migration files are up to date.
1. [x] The changes are tested.
1. [x] GraphQL schema and type definitions are up to date.
1. [x] Changes are mentioned in the changelog.


Problem statement:
Newly registered user e-mail confirmation
I see that according to previous issues and pull requests, that e-mail address confirmation for new users was a feature in the past but currently is not. I would like to add it to my site, I can work on it as a pull request for Saleor (mostly taken from the django-allauth implementation, it would be enabled/disabled in settings.py) but first I wanted to check if it's something that would likely be added to Saleor. Also wanted to ask if there is already an easy way to enable it that perhaps I'm missing?

### What I'm trying to achieve

...Users need to confirm their e-mail address before logging in

### Steps to reproduce the problem

1.User registers
2.User is immediately logged in, no activation e-mail

### What I expected to happen

...User needs to confirm e-mail address via e-mail activation link

### What happened instead/how it failed

...User is immediately allowed to login

Hints from issue discussion:
Hi @primal100, there's currently no such feature but I think it would be very useful to have it by default 
 in Saleor with the ability to switch it through settings. If you are willing to add it, a PR would be welcomed :)

I would start with customizing the `SignupForm` to send the email upon successful registration. It should work similarly to how the "password reset" feature works in terms of separating logic and organizing templates. If you need any further help, feel free to ask.
I'm working on this here:

https://github.com/primal100/saleor/tree/feature/user-email-confirmation

It's working well. The one thing I am not sure about is adding the additional strings for messages to the .po files and integrating with transifex. I'm guessing something like:

1) ugettext_lazy for all strings to be contained in messages
2) Add the new strings manually to saleor/dashboard/locale/en/LC_MESSAGES/django.po 
3) Run some script to add them to the other .po files and integrate with transifex?

Any help appreciated.
Hi @primal100, for translations it will be enough if you use `pgettext` (or `pgettext_lazy`) in code and `trans` (or `blocktrans`) in templates. Also, it would be nice if you use `django-templated-email` package for sending e-mails. You can see how it is used in `PasswordSetUpForm`'s `send_mail()` method for example.
Could you please open a Pull Request based on your changes?
@maarcingebala Can we get back to this issue? I think it will be needed for most of the big players using Saleor.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

Any update? Is there any way to activate activation email link for user?
Not yet, but @tomaszszymanski129 is on it, expect a PR soon :)

Commit message: Update schema

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b3fc68f8323..dfc712089bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@ All notable, unreleased changes to this project will be documented in this file.
 
 ## [Unreleased]
 
+- Account confirmation email - #5126 by @tomaszszymanski129
 - Relocate Checkout and CheckoutLine methods into separate module and update checkout related plugins to use them - #4980 by @krzysztofwolski
 - Fix problem with free shipping voucher - #4942 by @IKarbowiak
 - Add sub-categories to random data - #4949 by @IKarbowiak
diff --git a/saleor/account/emails.py b/saleor/account/emails.py
index 1884da302d7..5efa70b98bb 100644
--- a/saleor/account/emails.py
+++ b/saleor/account/emails.py
@@ -19,6 +19,25 @@ def send_user_password_reset_email_with_url(redirect_url, user):
     _send_password_reset_email_with_url.delay(user.email, redirect_url, user.pk, token)
 
 
+def send_account_confirmation_email(user, redirect_origin):
+    """Trigger sending an account confirmation email for the given user."""
+    token = default_token_generator.make_token(user)
+    _send_account_confirmation_email.delay(user.email, token, redirect_origin)
+
+
+@app.task
+def _send_account_confirmation_email(email, token, redirect_origin):
+    confirm_url = f"{redirect_origin}/account-confirm?email={email}&token={token}"
+    send_kwargs, ctx = get_email_context()
+    ctx["confirm_url"] = confirm_url
+    send_templated_mail(
+        template_name="account/confirm",
+        recipient_list=[email],
+        context=ctx,
+        **send_kwargs,
+    )
+
+
 @app.task
 def _send_password_reset_email_with_url(recipient_email, redirect_url, user_id, token):
     params = urlencode({"email": recipient_email, "token": token})
diff --git a/saleor/graphql/account/mutations/account.py b/saleor/graphql/account/mutations/account.py
index afb102449ce..3c622dcd129 100644
--- a/saleor/graphql/account/mutations/account.py
+++ b/saleor/graphql/account/mutations/account.py
@@ -1,4 +1,5 @@
 import graphene
+from django.conf import settings
 from django.contrib.auth.tokens import default_token_generator
 from django.core.exceptions import ValidationError
 
@@ -28,6 +29,11 @@
 class AccountRegisterInput(graphene.InputObjectType):
     email = graphene.String(description="The email address of the user.", required=True)
     password = graphene.String(description="Password.", required=True)
+    redirect_origin = graphene.String(
+        description="Base of frontend URL that will be "
+        "needed to create confirmation URL.",
+        required=True,
+    )
 
 
 class AccountRegister(ModelMutation):
@@ -43,11 +49,28 @@ class Meta:
         error_type_class = AccountError
         error_type_field = "account_errors"
 
+    @classmethod
+    def clean_input(cls, info, instance, data, input_cls=None):
+        try:
+            validate_storefront_url(data["redirect_origin"])
+        except ValidationError as error:
+            raise ValidationError(
+                {"redirect_origin": error}, code=AccountErrorCode.INVALID
+            )
+        return super().clean_input(info, instance, data, input_cls=None)
+
     @classmethod
     def save(cls, info, user, cleaned_input):
         password = cleaned_input["password"]
         user.set_password(password)
-        user.save()
+        if settings.ENABLE_ACCOUNT_CONFIRMATION_BY_EMAIL:
+            user.is_active = False
+            user.save()
+            emails.send_account_confirmation_email(
+                user, cleaned_input["redirect_origin"]
+            )
+        else:
+            user.save()
         account_events.customer_account_created_event(user=user)
         info.context.extensions.customer_created(customer=user)
 
diff --git a/saleor/graphql/account/mutations/base.py b/saleor/graphql/account/mutations/base.py
index 34dc5bdf218..88bde3484d6 100644
--- a/saleor/graphql/account/mutations/base.py
+++ b/saleor/graphql/account/mutations/base.py
@@ -153,6 +153,44 @@ def perform_mutation(cls, _root, info, **data):
         return RequestPasswordReset()
 
 
+class ConfirmAccount(BaseMutation):
+    class Arguments:
+        token = graphene.String(
+            description="A one-time token required to set the password.", required=True
+        )
+        email = graphene.String(
+            description="E-mail of the user performing account confirmation.",
+            required=True,
+        )
+
+    class Meta:
+        description = "Confirm user account by token sent by email during registration"
+
+    @classmethod
+    def perform_mutation(cls, _root, info, **data):
+        try:
+            user = models.User.objects.get(email=data["email"])
+        except ObjectDoesNotExist:
+            raise ValidationError(
+                {
+                    "email": ValidationError(
+                        "User with this email doesn't exist",
+                        code=AccountErrorCode.NOT_FOUND,
+                    )
+                }
+            )
+
+        if not default_token_generator.check_token(user, data["token"]):
+            raise ValidationError(
+                {"token": ValidationError(INVALID_TOKEN, code=AccountErrorCode.INVALID)}
+            )
+
+        user.is_active = True
+        user.save()
+
+        return ConfirmAccount()
+
+
 class PasswordChange(BaseMutation):
     user = graphene.Field(User, description="A user instance with a new password.")
 
diff --git a/saleor/graphql/account/schema.py b/saleor/graphql/account/schema.py
index db27a0385df..24cc44fa256 100644
--- a/saleor/graphql/account/schema.py
+++ b/saleor/graphql/account/schema.py
@@ -22,6 +22,7 @@
     RequestEmailChange,
 )
 from .mutations.base import (
+    ConfirmAccount,
     PasswordChange,
     RequestPasswordReset,
     SetPassword,
@@ -183,6 +184,7 @@ def resolve_address(self, info, id):
 class AccountMutations(graphene.ObjectType):
     # Base mutations
     request_password_reset = RequestPasswordReset.Field()
+    confirm_account = ConfirmAccount.Field()
     set_password = SetPassword.Field()
     password_change = PasswordChange.Field()
     request_email_change = RequestEmailChange.Field()
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index 7a194d13ab1..4488031999f 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -73,6 +73,7 @@ type AccountRegister {
 input AccountRegisterInput {
   email: String!
   password: String!
+  redirectOrigin: String!
 }
 
 type AccountRequestDeletion {
@@ -1027,6 +1028,10 @@ enum ConfigurationTypeFieldEnum {
   PASSWORD
 }
 
+type ConfirmAccount {
+  errors: [Error!]
+}
+
 type ConfirmEmailChange {
   errors: [Error!]
   user: User
@@ -2268,6 +2273,7 @@ type Mutation {
   checkoutUpdatePrivateMetadata(id: ID!, input: MetaInput!): CheckoutUpdatePrivateMeta
   checkoutClearPrivateMetadata(id: ID!, input: MetaPath!): CheckoutClearPrivateMeta
   requestPasswordReset(email: String!, redirectUrl: String!): RequestPasswordReset
+  confirmAccount(email: String!, token: String!): ConfirmAccount
   setPassword(token: String!, email: String!, password: String!): SetPassword
   passwordChange(newPassword: String!, oldPassword: String!): PasswordChange
   requestEmailChange(newEmail: String!, password: String!, redirectUrl: String!): RequestEmailChange
diff --git a/saleor/settings.py b/saleor/settings.py
index b0032c8b5e3..72256960b2e 100644
--- a/saleor/settings.py
+++ b/saleor/settings.py
@@ -128,6 +128,8 @@ def get_bool_from_env(name, default_value):
 EMAIL_USE_TLS = email_config["EMAIL_USE_TLS"]
 EMAIL_USE_SSL = email_config["EMAIL_USE_SSL"]
 
+ENABLE_ACCOUNT_CONFIRMATION_BY_EMAIL = True
+
 ENABLE_SSL = get_bool_from_env("ENABLE_SSL", False)
 
 if ENABLE_SSL:
diff --git a/templates/templated_email/account/confirm.email b/templates/templated_email/account/confirm.email
new file mode 100644
index 00000000000..981b9dba318
--- /dev/null
+++ b/templates/templated_email/account/confirm.email
@@ -0,0 +1,21 @@
+{% load i18n %}
+
+{% block subject %}
+  {% trans "Account confirmation e-mail" context "Account confirmation e-mail subject" %}
+{% endblock %}
+
+{% block plain %}
+{% include 'templated_email/shared/_header.email' %}
+{% blocktrans context "Account confirmation e-mail text" %}
+In order to log into {{domain}}, you have to confirm your email address first.
+Please click the link below to do so and log into your account.
+{% endblocktrans %}
+
+{{ confirm_url }}
+
+{% include 'templated_email/shared/_footer.email' %}
+{% endblock %}
+
+{% block html %}
+{% include 'templated_email/compiled/confirm.html' %}
+{% endblock %}
diff --git a/templates/templated_email/source/confirm.mjml b/templates/templated_email/source/confirm.mjml
new file mode 100644
index 00000000000..bece3c6da95
--- /dev/null
+++ b/templates/templated_email/source/confirm.mjml
@@ -0,0 +1,30 @@
+<mjml>
+  <mj-head>
+    <mj-include path="./shared/styles.mjml" />
+  </mj-head>
+  <mj-body>
+    <mj-raw>
+      {% load i18n %}
+    </mj-raw>
+    <mj-include path="./shared/header.mjml" />
+    <mj-section>
+      <mj-column>
+        <mj-text font-size="16px">
+          {% trans "Hi!" context "Standard e-mail greeting" %}
+        </mj-text>
+        <mj-text>
+          {% blocktrans trimmed context "Account confirmation e-mail text" %}
+            In order to log into {{domain}}, you have to confirm your email address first.
+            Please click the link below to do so and log into your account.
+          {% endblocktrans %}
+        </mj-text>
+        <mj-text>
+          <a href="{{ confirm_url }}">
+            {{ confirm_url }}
+          </a>
+        </mj-text>
+      </mj-column>
+    </mj-section>
+    <mj-include path="./shared/footer.mjml" />
+  </mj-body>
+</mjml>
diff --git a/tests/api/test_account.py b/tests/api/test_account.py
index bb063fe1da1..39cfe10ed25 100644
--- a/tests/api/test_account.py
+++ b/tests/api/test_account.py
@@ -9,6 +9,7 @@
 from django.core.exceptions import ValidationError
 from django.core.files import File
 from django.core.validators import URLValidator
+from django.test import override_settings
 from freezegun import freeze_time
 from prices import Money
 
@@ -564,8 +565,18 @@ def test_user_with_cancelled_fulfillments(
 
 
 ACCOUNT_REGISTER_MUTATION = """
-    mutation RegisterAccount($password: String!, $email: String!) {
-        accountRegister(input: {password: $password, email: $email}) {
+    mutation RegisterAccount(
+        $password: String!,
+        $email: String!,
+        $redirectOrigin: String!
+    ) {
+        accountRegister(
+            input: {
+                password: $password,
+                email: $email,
+                redirectOrigin: $redirectOrigin
+            }
+        ) {
             errors {
                 field
                 message
@@ -578,15 +589,24 @@ def test_user_with_cancelled_fulfillments(
 """
 
 
-def test_customer_register(user_api_client):
+@override_settings(
+    ENABLE_ACCOUNT_CONFIRMATION_BY_EMAIL=True, ALLOWED_CLIENT_HOSTS=["localhost"]
+)
+@patch("saleor.account.emails._send_account_confirmation_email")
+def test_customer_register(send_account_confirmation_email_mock, user_api_client):
     email = "customer@example.com"
-    variables = {"email": email, "password": "Password"}
+    variables = {
+        "email": email,
+        "password": "Password",
+        "redirectOrigin": "http://localhost:3000",
+    }
     query = ACCOUNT_REGISTER_MUTATION
     mutation_name = "accountRegister"
     response = user_api_client.post_graphql(query, variables)
     content = get_graphql_content(response)
     data = content["data"][mutation_name]
     assert not data["errors"]
+    assert send_account_confirmation_email_mock.delay.call_count == 1
     new_user = User.objects.get(email=email)
 
     response = user_api_client.post_graphql(query, variables)
@@ -601,6 +621,16 @@ def test_customer_register(user_api_client):
     assert customer_creation_event.user == new_user
 
 
+@override_settings(ENABLE_ACCOUNT_CONFIRMATION_BY_EMAIL=False)
+@patch("saleor.account.emails._send_account_confirmation_email")
+def test_customer_register_disabled_email_confirmation(
+    send_account_confirmation_email_mock, user_api_client
+):
+    variables = {"email": "customer@example.com", "password": "Password"}
+    user_api_client.post_graphql(ACCOUNT_REGISTER_MUTATION, variables)
+    assert send_account_confirmation_email_mock.delay.call_count == 0
+
+
 CUSTOMER_CREATE_MUTATION = """
     mutation CreateCustomer(
         $email: String, $firstName: String, $lastName: String,
@@ -2097,6 +2127,18 @@ def test_address_validation_rules_fields_in_camel_case(user_api_client):
 """
 
 
+CONFIRM_ACCOUNT_MUTATION = """
+    mutation ConfirmAccount($email: String!, $token: String!) {
+        confirmAccount(email: $email, token: $token) {
+            errors {
+                field
+                message
+            }
+        }
+    }
+"""
+
+
 @patch("saleor.account.emails._send_password_reset_email")
 def test_account_reset_password(
     send_password_reset_email_mock, user_api_client, customer_user
@@ -2115,6 +2157,41 @@ def test_account_reset_password(
     url_validator(url)
 
 
+def test_account_confirmation(user_api_client, customer_user):
+    customer_user.is_active = False
+    customer_user.save()
+
+    variables = {
+        "email": customer_user.email,
+        "token": default_token_generator.make_token(customer_user),
+    }
+    user_api_client.post_graphql(CONFIRM_ACCOUNT_MUTATION, variables)
+
+    customer_user.refresh_from_db()
+    assert customer_user.is_active is True
+
+
+def test_account_confirmation_invalid_user(user_api_client, customer_user):
+    variables = {
+        "email": "non-existing@nope.com",
+        "token": default_token_generator.make_token(customer_user),
+    }
+    response = user_api_client.post_graphql(CONFIRM_ACCOUNT_MUTATION, variables)
+    content = get_graphql_content(response)
+    assert content["data"]["confirmAccount"]["errors"] == [
+        {"field": "email", "message": "User with this email doesn't exist"}
+    ]
+
+
+def test_account_confirmation_invalid_token(user_api_client, customer_user):
+    variables = {"email": customer_user.email, "token": "invalid_token"}
+    response = user_api_client.post_graphql(CONFIRM_ACCOUNT_MUTATION, variables)
+    content = get_graphql_content(response)
+    assert content["data"]["confirmAccount"]["errors"] == [
+        {"field": "token", "message": "Invalid or expired token."}
+    ]
+
+
 @patch("saleor.account.emails._send_password_reset_email")
 def test_request_password_reset_email_for_staff(
     send_password_reset_email_mock, staff_api_client


Code review comments:
diff_hunk: @@ -153,6 +153,44 @@ def perform_mutation(cls, _root, info, **data):
         return RequestPasswordReset()
 
 
+class ConfirmAccount(BaseMutation):
+    class Arguments:
+        token = graphene.String(
+            description="A one-time token required to set the password.", required=True
+        )
+        email = graphene.String(
+            description="E-mail of the user performing account confirmation.",
+            required=True,
+        )
+
+    class Meta:
+        description = "Confirm user account by token sent by email during registration"
+
+    @classmethod
+    def perform_mutation(cls, _root, info, **data):
+        try:
+            user = models.User.objects.get(email=data["email"])
+        except ObjectDoesNotExist:
+            raise ValidationError(
+                {
+                    "email": ValidationError(
+                        "User with this email doesn't exist",
+                        code=AccountErrorCode.NOT_FOUND,
+                    )
+                }
+            )
+
+        if not default_token_generator.check_token(user, data["token"]):
+            raise ValidationError(
+                {"token": ValidationError(INVALID_TOKEN, code=AccountErrorCode.INVALID)}
+            )
+
+        user.is_active = True
+        user.save()
path: saleor/graphql/account/mutations/base.py
text: @user1:
```suggestion
        user.save(update_fields=["is_active"])
```

diff_hunk: @@ -128,6 +128,8 @@ def get_bool_from_env(name, default_value):
 EMAIL_USE_TLS = email_config["EMAIL_USE_TLS"]
 EMAIL_USE_SSL = email_config["EMAIL_USE_SSL"]
 
+ENABLE_ACCOUNT_CONFIRMATION_BY_EMAIL = True
path: saleor/settings.py
text: @user1:
It should take the value from env and shouldn't be true by default, otherwise it could be a breaking or unwanted change

@user1:
cc @user2 that could be up for discussion whether it should be `True` or `False` out of the box. Or maybe later on.

@author:
@user1 Discussed with Marcin, we think it should be enabled by default as it's the default for most of the internet.

diff_hunk: @@ -2115,6 +2157,41 @@ def test_account_reset_password(
     url_validator(url)
 
 
+def test_account_confirmation(user_api_client, customer_user):
+    customer_user.is_active = False
+    customer_user.save()
+
+    variables = {
+        "email": customer_user.email,
+        "token": default_token_generator.make_token(customer_user),
+    }
+    user_api_client.post_graphql(CONFIRM_ACCOUNT_MUTATION, variables)
+
+    customer_user.refresh_from_db()
+    assert customer_user.is_active is True
+
+
+def test_account_confirmation_invalid_user(user_api_client, customer_user):
+    variables = {
+        "email": "non-existing@nope.com",
path: tests/api/test_account.py
text: @user2:
RFC
```suggestion
        "email": "non-existing@user1.com",
```