Преглед на файлове

Merge remote-tracking branch 'nguyenkims/master'

Tung Nguyen преди 5 години
родител
ревизия
1289b08636
променени са 49 файла, в които са добавени 1071 реда и са изтрити 287 реда
  1. 5 2
      .env.example
  2. 4 0
      .github/workflows/pythonpackage.yml
  3. 27 6
      README.md
  4. 1 0
      app/auth/__init__.py
  5. 33 0
      app/auth/templates/auth/mfa.html
  6. 3 5
      app/auth/views/facebook.py
  7. 6 11
      app/auth/views/github.py
  8. 4 7
      app/auth/views/google.py
  9. 5 13
      app/auth/views/login.py
  10. 30 0
      app/auth/views/login_utils.py
  11. 51 0
      app/auth/views/mfa.py
  12. 17 2
      app/config.py
  13. 3 0
      app/dashboard/__init__.py
  14. 2 0
      app/dashboard/templates/dashboard/api_key.html
  15. 6 96
      app/dashboard/templates/dashboard/custom_domain.html
  16. 217 0
      app/dashboard/templates/dashboard/domain_detail.html
  17. 41 15
      app/dashboard/templates/dashboard/index.html
  18. 28 0
      app/dashboard/templates/dashboard/mfa_cancel.html
  19. 51 0
      app/dashboard/templates/dashboard/mfa_setup.html
  20. 11 7
      app/dashboard/templates/dashboard/pricing.html
  21. 32 1
      app/dashboard/templates/dashboard/setting.html
  22. 6 53
      app/dashboard/views/custom_domain.py
  23. 106 0
      app/dashboard/views/domain_detail.py
  24. 17 2
      app/dashboard/views/index.py
  25. 37 0
      app/dashboard/views/mfa_cancel.py
  26. 49 0
      app/dashboard/views/mfa_setup.py
  27. 9 0
      app/dashboard/views/setting.py
  28. 41 3
      app/dns_utils.py
  29. 44 6
      app/models.py
  30. 13 4
      email_handler.py
  31. 6 0
      local_data/dkim.pub.key
  32. 1 1
      migrations/alembic.ini
  33. 29 0
      migrations/versions/2019_122910_696e17c13b8b_.py
  34. 29 0
      migrations/versions/2019_122910_e409f6214b2b_.py
  35. 29 0
      migrations/versions/9e1b06b9df13_.py
  36. 31 0
      migrations/versions/d4e4488a0032_.py
  37. 2 1
      requirements.in
  38. 3 2
      requirements.txt
  39. 1 0
      server.py
  40. BIN
      static/logo.png
  41. 4 0
      static/style.css
  42. 1 1
      templates/default.html
  43. 0 24
      templates/emails/welcome.html
  44. 0 8
      templates/emails/welcome.txt
  45. 1 15
      templates/header.html
  46. 23 0
      templates/menu.html
  47. 2 2
      templates/single.html
  48. 1 0
      tests/env.test
  49. 9 0
      tests/test_models.py

+ 5 - 2
.env.example

@@ -31,6 +31,9 @@ EMAIL_SERVERS_WITH_PRIORITY=[(10, "email.hostname.")]
 
 # the DKIM private key used to compute DKIM-Signature
 DKIM_PRIVATE_KEY_PATH=local_data/dkim.key
+
+# the DKIM public key used to setup custom domain DKIM
+DKIM_PUBLIC_KEY_PATH=local_data/dkim.pub.key
 # <<< END Email related settings >>>
 
 
@@ -55,8 +58,8 @@ AWS_SECRET_ACCESS_KEY=to_fill
 
 # Cloudwatch
 # ENABLE_CLOUDWATCH=true
-CLOUDWATCH_LOG_GROUP=local
-CLOUDWATCH_LOG_STREAM=local
+# CLOUDWATCH_LOG_GROUP=local
+# CLOUDWATCH_LOG_STREAM=local
 # <<< END AWS >>>
 
 # Paddle

+ 4 - 0
.github/workflows/pythonpackage.yml

@@ -25,3 +25,7 @@ jobs:
       run: |
         pip install pytest
         pytest
+    - name: Test formatting
+      run: |
+        pip install black
+        black --check .

+ 27 - 6
README.md

@@ -228,7 +228,7 @@ To run the server, you need a config file. Please have a look at [config example
 
 Let's put your config file at `~/simplelogin.env`.
 
-Make sure to update the following variables
+Make sure to update the following variables and replace these values by yours.
 
 ```.env
 # Server url
@@ -237,6 +237,8 @@ EMAIL_DOMAIN=mydomain.com
 SUPPORT_EMAIL=support@mydomain.com
 EMAIL_SERVERS_WITH_PRIORITY=[(10, "app.mydomain.com.")]
 DKIM_PRIVATE_KEY_PATH=/dkim.key
+DKIM_PUBLIC_KEY_PATH=/dkim.pub.key
+DB_URI=postgresql://myuser:mypassword@sl-db:5432/simplelogin
 
 # optional, to have more choices for random alias.
 WORDS_FILE_PATH=local_data/words_alpha.txt
@@ -246,9 +248,10 @@ WORDS_FILE_PATH=local_data/words_alpha.txt
 Before running the webapp, you need to prepare the database by running the migration
 
 ```bash
-docker run \
+docker run --rm \
     --name sl-migration \
     -v $(pwd)/dkim.key:/dkim.key \
+    -v $(pwd)/dkim.pub.key:/dkim.pub.key \
     -v $(pwd)/simplelogin.env:/code/.env \
     --network="sl-network" \
     simplelogin/app flask db upgrade
@@ -263,6 +266,7 @@ docker run -d \
     --name sl-app \
     -v $(pwd)/simplelogin.env:/code/.env \
     -v $(pwd)/dkim.key:/dkim.key \
+    -v $(pwd)/dkim.pub.key:/dkim.pub.key \
     -p 7777:7777 \
     --network="sl-network" \
     simplelogin/app
@@ -275,6 +279,7 @@ docker run -d \
     --name sl-email \
     -v $(pwd)/simplelogin.env:/code/.env \
     -v $(pwd)/dkim.key:/dkim.key \
+    -v $(pwd)/dkim.pub.key:/dkim.pub.key \
     -p 20381:20381 \
     --network="sl-network" \
     simplelogin/app python email_handler.py
@@ -287,6 +292,7 @@ docker run -d \
     --name sl-cron \
     -v $(pwd)/simplelogin.env:/code/.env \
     -v $(pwd)/dkim.key:/dkim.key \
+    -v $(pwd)/dkim.pub.key:/dkim.pub.key \
     --network="sl-network" \
     simplelogin/app yacron -c /code/crontab.yml
 ```
@@ -329,7 +335,7 @@ All work on SimpleLogin happens directly on GitHub.
 
 ### Run code locally
 
-The project uses Python 3.6+. First, install all dependencies by running the following command. Feel free to use `virtualenv` or similar tools to isolate development environment.
+The project uses Python 3.7+. First, install all dependencies by running the following command. Feel free to use `virtualenv` or similar tools to isolate development environment.
 
 ```bash
 pip3 install -r requirements.txt
@@ -396,7 +402,16 @@ Response: a json with following structure. ? means optional field.
 		[email1, email2, ...]
 ```
 
-- `/alias/custom/new`: allows user to create a new customised alias.
+- `/alias/custom/new`: allows user to create a new custom alias.
+
+To try out the endpoint, you can use the following command. The command uses [httpie](https://httpie.org). 
+Make sure to replace `{api_key}` by your API Key obtained on https://app.simplelogin.io/dashboard/api_key
+
+```
+http https://app.simplelogin.io/api/alias/options \
+    Authentication:{api_key} \
+    hostname==www.google.com
+```
 
 ```
 POST /alias/custom/new
@@ -417,11 +432,17 @@ Whenever the model changes, a new migration has to be created
 
 Set the database connection to use a current database (i.e. the one without the model changes you just made), for example, if you have a staging config at `~/config/simplelogin/staging.env`, you can do: 
 
-> ln -sf ~/config/simplelogin/staging.env .env
+```bash
+ln -sf ~/config/simplelogin/staging.env .env
+```
 
 Generate the migration script and make sure to review it before committing it. Sometimes (very rarely though), the migration generation can go wrong.
 
-> flask db migrate
+```bash
+flask db migrate
+```
+
+In local the database creation in Sqlite doesn't use migration and uses directly `db.create_all()` (cf `fake_data()` method). This is because Sqlite doesn't handle well the migration. As sqlite is only used during development, the database is deleted and re-populated at each run.
 
 ### Code structure
 

+ 1 - 0
app/auth/__init__.py

@@ -10,4 +10,5 @@ from .views import (
     google,
     facebook,
     change_email,
+    mfa,
 )

+ 33 - 0
app/auth/templates/auth/mfa.html

@@ -0,0 +1,33 @@
+{% extends "single.html" %}
+
+
+{% block title %}
+  MFA
+{% endblock %}
+
+
+{% block single_content %}
+  <div class="bg-white p-6" style="margin: auto">
+
+    <div>
+      Your account is protected with multi-factor authentication (MFA). <br>
+      To continue with the sign-in you need to provide the access code from your authenticator.
+    </div>
+
+    <form method="post">
+      {{ otp_token_form.csrf_token }}
+      <input type="hidden" name="form-name" value="create">
+
+      <div class="font-weight-bold mt-5">Token</div>
+      <div class="small-text">Please enter the 6-digit number displayed in your MFA application (Google Authenticator,
+        Authy) here
+      </div>
+
+      {{ otp_token_form.token(class="form-control", placeholder="") }}
+      {{ render_field_errors(otp_token_form.token) }}
+      <button class="btn btn-success mt-2">Validate</button>
+    </form>
+
+  </div>
+
+{% endblock %}

+ 3 - 5
app/auth/views/facebook.py

@@ -10,6 +10,7 @@ from app.config import URL, FACEBOOK_CLIENT_ID, FACEBOOK_CLIENT_SECRET
 from app.extensions import db
 from app.log import LOG
 from app.models import User
+from .login_utils import after_login
 
 _authorization_base_url = "https://www.facebook.com/dialog/oauth"
 _token_url = "https://graph.facebook.com/oauth/access_token"
@@ -99,7 +100,6 @@ def facebook_callback():
             user.profile_picture_id = file.id
             db.session.commit()
 
-        login_user(user)
     # create user
     else:
         LOG.d("create facebook user with %s", facebook_user_data)
@@ -116,6 +116,7 @@ def facebook_callback():
 
         flash(f"Welcome to SimpleLogin {user.name}!", "success")
 
+    next_url = None
     # The activation link contains the original page, for ex authorize page
     if "facebook_next_url" in session:
         next_url = session["facebook_next_url"]
@@ -124,7 +125,4 @@ def facebook_callback():
         # reset the next_url to avoid user getting redirected at each login :)
         session.pop("facebook_next_url", None)
 
-        return redirect(next_url)
-    else:
-        LOG.debug("redirect user to dashboard")
-        return redirect(url_for("dashboard.index"))
+    return after_login(user, next_url)

+ 6 - 11
app/auth/views/github.py

@@ -1,9 +1,10 @@
-from flask import request, session, redirect, url_for, flash
+from flask import request, session, redirect, flash
 from flask_login import login_user
 from requests_oauthlib import OAuth2Session
 
 from app import email_utils
 from app.auth.base import auth_bp
+from app.auth.views.login_utils import after_login
 from app.config import GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, URL
 from app.extensions import db
 from app.log import LOG
@@ -81,10 +82,8 @@ def github_callback():
 
     user = User.get_by(email=email)
 
-    if user:
-        login_user(user)
     # create user
-    else:
+    if not user:
         LOG.d("create github user")
         user = User.create(
             email=email, name=github_user_data.get("name") or "", activated=True
@@ -96,10 +95,6 @@ def github_callback():
         flash(f"Welcome to SimpleLogin {user.name}!", "success")
 
     # The activation link contains the original page, for ex authorize page
-    if "next" in request.args:
-        next_url = request.args.get("next")
-        LOG.debug("redirect user to %s", next_url)
-        return redirect(next_url)
-    else:
-        LOG.debug("redirect user to dashboard")
-        return redirect(url_for("dashboard.index"))
+    next_url = request.args.get("next") if request.args else None
+
+    return after_login(user, next_url)

+ 4 - 7
app/auth/views/google.py

@@ -1,4 +1,4 @@
-from flask import request, session, redirect, url_for, flash
+from flask import request, session, redirect, flash
 from flask_login import login_user
 from requests_oauthlib import OAuth2Session
 
@@ -9,6 +9,7 @@ from app.extensions import db
 from app.log import LOG
 from app.models import User, File
 from app.utils import random_string
+from .login_utils import after_login
 
 _authorization_base_url = "https://accounts.google.com/o/oauth2/v2/auth"
 _token_url = "https://www.googleapis.com/oauth2/v4/token"
@@ -89,8 +90,6 @@ def google_callback():
             file = create_file_from_url(picture_url)
             user.profile_picture_id = file.id
             db.session.commit()
-
-        login_user(user)
     # create user
     else:
         LOG.d("create google user with %s", google_user_data)
@@ -107,6 +106,7 @@ def google_callback():
 
         flash(f"Welcome to SimpleLogin {user.name}!", "success")
 
+    next_url = None
     # The activation link contains the original page, for ex authorize page
     if "google_next_url" in session:
         next_url = session["google_next_url"]
@@ -115,10 +115,7 @@ def google_callback():
         # reset the next_url to avoid user getting redirected at each login :)
         session.pop("google_next_url", None)
 
-        return redirect(next_url)
-    else:
-        LOG.debug("redirect user to dashboard")
-        return redirect(url_for("dashboard.index"))
+    return after_login(user, next_url)
 
 
 def create_file_from_url(url) -> File:

+ 5 - 13
app/auth/views/login.py

@@ -1,9 +1,10 @@
 from flask import request, render_template, redirect, url_for, flash
-from flask_login import login_user, current_user
+from flask_login import current_user
 from flask_wtf import FlaskForm
 from wtforms import StringField, validators
 
 from app.auth.base import auth_bp
+from app.auth.views.login_utils import after_login
 from app.log import LOG
 from app.models import User
 
@@ -27,9 +28,9 @@ def login():
         user = User.filter_by(email=form.email.data).first()
 
         if not user:
-            flash("The email entered does not exist in our system", "error")
+            flash("Email or password incorrect", "error")
         elif not user.check_password(form.password.data):
-            flash("Wrong password", "error")
+            flash("Email or password incorrect", "error")
         elif not user.activated:
             show_resend_activation = True
             flash(
@@ -37,16 +38,7 @@ def login():
                 "error",
             )
         else:
-            LOG.debug("log user %s in", user)
-            login_user(user)
-
-            # User comes to login page from another page
-            if next_url:
-                LOG.debug("redirect user to %s", next_url)
-                return redirect(next_url)
-            else:
-                LOG.debug("redirect user to dashboard")
-                return redirect(url_for("dashboard.index"))
+            return after_login(user, next_url)
 
     return render_template(
         "auth/login.html",

+ 30 - 0
app/auth/views/login_utils.py

@@ -0,0 +1,30 @@
+from flask import session, redirect, url_for
+from flask_login import login_user
+
+from app.config import MFA_USER_ID
+from app.log import LOG
+
+
+def after_login(user, next_url):
+    """
+    Redirect to the correct page after login.
+    If user enables MFA: redirect user to MFA page
+    Otherwise redirect to dashboard page if no next_url
+    """
+    if user.enable_otp:
+        session[MFA_USER_ID] = user.id
+        if next_url:
+            return redirect(url_for("auth.mfa", next_url=next_url))
+        else:
+            return redirect(url_for("auth.mfa"))
+    else:
+        LOG.debug("log user %s in", user)
+        login_user(user)
+
+        # User comes to login page from another page
+        if next_url:
+            LOG.debug("redirect user to %s", next_url)
+            return redirect(next_url)
+        else:
+            LOG.debug("redirect user to dashboard")
+            return redirect(url_for("dashboard.index"))

+ 51 - 0
app/auth/views/mfa.py

@@ -0,0 +1,51 @@
+import pyotp
+from flask import request, render_template, redirect, url_for, flash, session
+from flask_login import login_user
+from flask_wtf import FlaskForm
+from wtforms import StringField, validators
+
+from app.auth.base import auth_bp
+from app.config import MFA_USER_ID
+from app.log import LOG
+from app.models import User
+
+
+class OtpTokenForm(FlaskForm):
+    token = StringField("Token", validators=[validators.DataRequired()])
+
+
+@auth_bp.route("/mfa", methods=["GET", "POST"])
+def mfa():
+    # passed from login page
+    user_id = session[MFA_USER_ID]
+    user = User.get(user_id)
+
+    if not user.enable_otp:
+        raise Exception("Only user with MFA enabled should go to this page. %s", user)
+
+    otp_token_form = OtpTokenForm()
+    next_url = request.args.get("next")
+
+    if otp_token_form.validate_on_submit():
+        totp = pyotp.TOTP(user.otp_secret)
+
+        token = otp_token_form.token.data
+
+        if totp.verify(token):
+            del session[MFA_USER_ID]
+
+            login_user(user)
+            flash(f"Welcome back {user.name}!")
+
+            # User comes to login page from another page
+            if next_url:
+                LOG.debug("redirect user to %s", next_url)
+                return redirect(next_url)
+            else:
+                LOG.debug("redirect user to dashboard")
+                return redirect(url_for("dashboard.index"))
+
+        else:
+            flash("Incorrect token", "warning")
+
+    return render_template("auth/mfa.html", otp_token_form=otp_token_form)

+ 17 - 2
app/config.py

@@ -58,11 +58,23 @@ else:
     IGNORED_EMAILS = []
 
 DKIM_PRIVATE_KEY_PATH = get_abs_path(os.environ["DKIM_PRIVATE_KEY_PATH"])
+DKIM_PUBLIC_KEY_PATH = get_abs_path(os.environ["DKIM_PUBLIC_KEY_PATH"])
 DKIM_SELECTOR = b"dkim"
 
 with open(DKIM_PRIVATE_KEY_PATH) as f:
     DKIM_PRIVATE_KEY = f.read()
 
+
+with open(DKIM_PUBLIC_KEY_PATH) as f:
+    DKIM_DNS_VALUE = (
+        f.read()
+        .replace("-----BEGIN PUBLIC KEY-----", "")
+        .replace("-----END PUBLIC KEY-----", "")
+        .replace("\r", "")
+        .replace("\n", "")
+    )
+
+
 DKIM_HEADERS = [b"from", b"to", b"subject"]
 
 # Database
@@ -77,9 +89,11 @@ BUCKET = os.environ["BUCKET"]
 AWS_ACCESS_KEY_ID = os.environ["AWS_ACCESS_KEY_ID"]
 AWS_SECRET_ACCESS_KEY = os.environ["AWS_SECRET_ACCESS_KEY"]
 
+CLOUDWATCH_LOG_GROUP = CLOUDWATCH_LOG_STREAM = ""
 ENABLE_CLOUDWATCH = "ENABLE_CLOUDWATCH" in os.environ
-CLOUDWATCH_LOG_GROUP = os.environ["CLOUDWATCH_LOG_GROUP"]
-CLOUDWATCH_LOG_STREAM = os.environ["CLOUDWATCH_LOG_STREAM"]
+if ENABLE_CLOUDWATCH:
+    CLOUDWATCH_LOG_GROUP = os.environ["CLOUDWATCH_LOG_GROUP"]
+    CLOUDWATCH_LOG_STREAM = os.environ["CLOUDWATCH_LOG_STREAM"]
 
 # Paddle
 PADDLE_VENDOR_ID = int(os.environ["PADDLE_VENDOR_ID"])
@@ -111,3 +125,4 @@ AVATAR_URL_EXPIRATION = 3600 * 24 * 7  # 1h*24h/d*7d=1week
 
 # session key
 HIGHLIGHT_GEN_EMAIL_ID = "highlight_gen_email_id"
+MFA_USER_ID = "mfa_user_id"

+ 3 - 0
app/dashboard/__init__.py

@@ -9,4 +9,7 @@ from .views import (
     api_key,
     custom_domain,
     alias_contact_manager,
+    mfa_setup,
+    mfa_cancel,
+    domain_detail,
 )

+ 2 - 0
app/dashboard/templates/dashboard/api_key.html

@@ -4,6 +4,8 @@
   API Key
 {% endblock %}
 
+{% set active_page = "api_key" %}
+
 {% block head %}
 {% endblock %}
 

+ 6 - 96
app/dashboard/templates/dashboard/custom_domain.html

@@ -1,4 +1,5 @@
 {% extends 'default.html' %}
+{% set active_page = "custom_domain" %}
 
 {% block title %}
   Custom Domains
@@ -16,9 +17,11 @@
         <div class="card" style="max-width: 50rem">
           <div class="card-body">
             <h5 class="card-title">
-              {{ custom_domain.domain }}
+              <a href="{{ url_for('dashboard.domain_detail', custom_domain_id=custom_domain.id) }}">{{ custom_domain.domain }}</a>
               {% if custom_domain.verified %}
-                <i class="fe fe-check" style="color: green"></i>
+                <span class="cursor" data-toggle="tooltip" data-original-title="Domain Verified">✅</span>
+              {% else %}
+                <span class="cursor" data-toggle="tooltip" data-original-title="Domain Not Verified">🚫 </span>
               {% endif %}
             </h5>
             <h6 class="card-subtitle mb-2 text-muted">
@@ -26,100 +29,7 @@
               <span class="font-weight-bold">{{ custom_domain.nb_alias() }}</span> aliases.
             </h6>
 
-            {% if not custom_domain.verified %}
-              <hr>
-              <div class="mb-3">Please follow the following steps to set up your domain: </div>
-
-              <div class="row">
-                <div class="col-1">
-                  <span class="badge badge-primary badge-pill">1</span>
-                </div>
-
-                <div class="col-11">
-                  Add the following MX DNS record to your domain
-                  {% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}
-                    <div class="ml-2 mb-3 p-3" style="background-color: #eee">
-                      Domain: <em>{{ custom_domain.domain }}</em> <br>
-                      Priority: 10 <br>
-                      Target: <em>{{ email_server }}</em> <br>
-                    </div>
-                  {% endfor %}
-
-                  Or if you edit your DNS record in text format, use the following code: <br>
-
-                  <pre class="ml-3">{% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}{{ custom_domain.domain }} IN MX {{ priority }} {{ email_server }}<br>{% endfor %}</pre>
-                </div>
-              </div>
-
-              <div class="row">
-                <div class="col-1"><span class="badge badge-primary badge-pill">2</span></div>
-                <div class="col-11">
-                  <span class="font-weight-bold">[Optional]</span>
-                  Setup <a href="https://en.wikipedia.org/wiki/Sender_Policy_Framework" target="_blank">
-                    SPF <i class="fe fe-external-link"></i></a> record.
-                  This can avoid emails forwarded to your personal inbox classified as spam. <br>
-                  Please note that some email providers can still classify these forwards as spam, in this case
-                  do not hesitate to create rules to avoid these emails mistakenly gone into spam.
-                  You can find how to whitelist a domain on
-                  <a href="https://www.simplelogin.io/help" target="_blank">Whitelist domain<i class="fe fe-external-link"></i></a><br>
-
-                  Please add the following TXT DNS record to your domain:
-
-                  <div class="ml-3 mb-2 p-3" style="background-color: #eee">
-                    Domain: <em>{{ custom_domain.domain }}</em> <br>
-                    Value:
-                    <em>
-                      v=spf1
-                      {% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}
-                        include:{{ email_server[:-1] }}
-                      {% endfor %} -all
-                    </em>
-                  </div>
-
-                  Or if you edit your DNS record in text format, use the following code: <br>
-
-                  <pre class="ml-3">{{ custom_domain.domain }} IN TXT "v=spf1 {% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}include:{{ email_server[:-1] }} {% endfor %}-all"</pre>
-                </div>
-              </div>
-
-              <div class="row">
-                <div class="col-1">
-                  <span class="badge badge-primary badge-pill mr-2">3</span>
-                </div>
-
-                <div class="col-11">
-                  Verify 👇🏽
-                  <form method="post">
-                    <input type="hidden" name="form-name" value="check-domain">
-                    <input type="hidden" name="custom-domain-id" value="{{ custom_domain.id }}">
-                    <button type="submit" class="btn btn-primary">Verify</button>
-                  </form>
-
-                  {% if custom_domain.id in errors %}
-                    <div class="text-danger">
-                      {{ errors.get(custom_domain.id) }}
-                    </div>
-                  {% endif %}
-
-                  As the change could take up to 24 hours, do not hesitate to come back to this page and verify again.
-                </div>
-              </div>
-            {% endif %}
-          </div>
-
-          <div class="card-footer">
-            <div class="row">
-              <div class="col">
-                <form method="post">
-                  <input type="hidden" name="form-name" value="delete">
-                  <input type="hidden" name="custom-domain-id" value="{{ custom_domain.id }}">
-                  <span class="card-link btn btn-link float-right delete-custom-domain">
-                    Delete
-                  </span>
-                </form>
-              </div>
-            </div>
-
+            <a href="{{ url_for('dashboard.domain_detail', custom_domain_id=custom_domain.id) }}">Details ➡</a>
           </div>
         </div>
       {% endfor %}

+ 217 - 0
app/dashboard/templates/dashboard/domain_detail.html

@@ -0,0 +1,217 @@
+{% extends 'default.html' %}
+{% set active_page = "custom_domain" %}
+
+{% block title %}
+  {{ custom_domain.domain }}
+{% endblock %}
+
+{% block head %}
+{% endblock %}
+
+{% block default_content %}
+  <div class="bg-white p-4" style="max-width: 60rem; margin: auto">
+    <h1 class="h3"> {{ custom_domain.domain }} </h1>
+    <div class="">Please follow the steps below to set up your domain.</div>
+
+    <div class="small-text mb-5">
+      DNS changes could take up to 24 hours to propagate. In practice, it's a lot faster though (~1
+      minute or in our experience).
+    </div>
+
+    <div>
+      <div class="font-weight-bold">1. MX record
+
+        {% if custom_domain.verified %}
+            <span class="cursor" data-toggle="tooltip" data-original-title="MX Record Verified">✅</span>
+        {% else %}
+            <span class="cursor" data-toggle="tooltip" data-original-title="MX Record Not Verified">🚫 </span>
+        {% endif %}
+      </div>
+
+      <div class="mb-2">Add the following MX DNS record to your domain</div>
+
+      {% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}
+        <div class="mb-3 p-3" style="background-color: #eee">
+          Domain: <em>{{ custom_domain.domain }}</em> <br>
+          Priority: 10 <br>
+          Target: <em>{{ email_server }}</em> <br>
+        </div>
+      {% endfor %}
+
+      <form method="post">
+        <input type="hidden" name="form-name" value="check-mx">
+        {% if custom_domain.verified %}
+          <button type="submit" class="btn btn-outline-primary">
+            Re-verify
+          </button>
+        {% else %}
+          <button type="submit" class="btn btn-primary">
+            Verify
+          </button>
+        {% endif %}
+      </form>
+
+      {% if not mx_ok %}
+        <div class="text-danger mt-4">
+          Your DNS is not correctly set. The MX record we obtain is:
+          <div class="mb-3 p-3" style="background-color: #eee">
+            {% for r in mx_errors %}
+              {{ r }} <br>
+            {% endfor %}
+          </div>
+          {% if custom_domain.verified %}
+            Please make sure to fix this ASAP - your aliases might not work properly.
+          {% endif %}
+        </div>
+      {% endif %}
+    </div>
+
+    <hr>
+
+    <div>
+      <div class="font-weight-bold">2. SPF (Optional)
+        {% if custom_domain.spf_verified %}
+            <span class="cursor" data-toggle="tooltip" data-original-title="SPF Verified">✅</span>
+        {% else %}
+            <span class="cursor" data-toggle="tooltip" data-original-title="SPF Not Verified">🚫 </span>
+        {% endif %}
+      </div>
+
+      <div>
+        SPF <a href="https://en.wikipedia.org/wiki/Sender_Policy_Framework" target="_blank">(Wikipedia↗)</a> is an email
+        authentication method
+        designed to detect forging sender addresses during the delivery of the email. <br>
+        Setting up SPF is highly recommended to reduce the chance your emails ending up in the recipient's Spam folder.
+      </div>
+
+      <div class="mb-2">Add the following TXT DNS record to your domain</div>
+
+      <div class="mb-2 p-3" style="background-color: #eee">
+        Domain: <em>{{ custom_domain.domain }}</em> <br>
+        Value:
+        <em>
+          {{ spf_record }}
+        </em>
+      </div>
+
+      <form method="post">
+        <input type="hidden" name="form-name" value="check-spf">
+        {% if custom_domain.spf_verified %}
+          <button type="submit" class="btn btn-outline-primary">
+            Re-verify
+          </button>
+        {% else %}
+          <button type="submit" class="btn btn-primary">
+            Verify
+          </button>
+        {% endif %}
+      </form>
+
+      {% if not spf_ok %}
+        <div class="text-danger mt-4">
+          Your DNS is not correctly set. The TXT record we obtain is:
+          <div class="mb-3 p-3" style="background-color: #eee">
+            {% for r in spf_errors %}
+              {{ r }} <br>
+            {% endfor %}
+          </div>
+          {% if custom_domain.spf_verified %}
+            Without SPF setup, emails you sent from your alias might end up in Spam/Junk folder.
+          {% endif %}
+        </div>
+      {% endif %}
+    </div>
+
+    <hr>
+
+    <div>
+      <div class="font-weight-bold">3. DKIM (Optional)
+        {% if custom_domain.dkim_verified %}
+            <span class="cursor" data-toggle="tooltip" data-original-title="SPF Verified">✅</span>
+        {% else %}
+            <span class="cursor" data-toggle="tooltip" data-original-title="DKIM Not Verified">🚫 </span>
+        {% endif %}
+      </div>
+
+      <div>
+        DKIM <a href="https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail" target="_blank">(Wikipedia↗)</a> is an
+        email
+        authentication method
+        designed to avoid email spoofing. <br>
+        Setting up DKIM is highly recommended to reduce the chance your emails ending up in the recipient's Spam folder.
+      </div>
+
+      <div class="mb-2">Add the following TXT DNS record to your domain</div>
+
+      <div class="mb-2 p-3" style="background-color: #eee">
+        Domain: <em>dkim._domainkey.{{ custom_domain.domain }}</em> <br>
+        Value:
+        <em style="overflow-wrap: break-word">
+          {{ dkim_record }}
+        </em>
+      </div>
+
+      <form method="post">
+        <input type="hidden" name="form-name" value="check-dkim">
+        {% if custom_domain.dkim_verified %}
+          <button type="submit" class="btn btn-outline-primary">
+            Re-verify
+          </button>
+        {% else %}
+          <button type="submit" class="btn btn-primary">
+            Verify
+          </button>
+        {% endif %}
+      </form>
+
+      {% if not dkim_ok %}
+        <div class="text-danger mt-4">
+          Your DNS is not correctly set.
+          {% if dkim_errors %}
+            The TXT record we obtain for
+            <em>dkim._domainkey.{{ custom_domain.domain }}</em> is:
+
+            <div class="mb-3 p-3" style="background-color: #eee">
+              {% for r in dkim_errors %}
+                {{ r }} <br>
+              {% endfor %}
+            </div>
+          {% endif %}
+
+          {% if custom_domain.dkim_verified %}
+            Without DKIM setup, emails you sent from your alias might end up in Spam/Junk folder.
+          {% endif %}
+        </div>
+      {% endif %}
+    </div>
+
+    <hr>
+    <h3 class="mb-0">Delete Domain</h3>
+    <div class="small-text mb-3">Please note that this operation is irreversible.
+      All aliases associated with this domain will be also deleted
+    </div>
+
+    <form method="post">
+      <input type="hidden" name="form-name" value="delete">
+      <span class="delete-custom-domain btn btn-outline-danger">Delete domain</span>
+    </form>
+
+  </div>
+{% endblock %}
+
+{% block script %}
+  <script>
+    $(".delete-custom-domain").on("click", function (e) {
+      notie.confirm({
+        text: "All aliases associated with <b>{{ custom_domain.domain }}</b> will be also deleted, " +
+          " please confirm.",
+        cancelCallback: () => {
+          // nothing to do
+        },
+        submitCallback: () => {
+          $(this).closest("form").submit();
+        }
+      });
+    });
+  </script>
+{% endblock %}

+ 41 - 15
app/dashboard/templates/dashboard/index.html

@@ -24,7 +24,7 @@
       </form>
     </div>
 
-    <div class="col-lg-3 offset-lg-6 pr-0 mt-1">
+    <div class="col-lg-4 offset-lg-5 pr-0 mt-1">
       <div class="btn-group float-right" role="group">
         <form method="post">
           <input type="hidden" name="form-name" value="create-custom-email">
@@ -33,17 +33,37 @@
                   class="btn btn-primary mr-2">New Email Alias
           </button>
         </form>
-        <form method="post">
-          <input type="hidden" name="form-name" value="create-random-email">
-          <button data-toggle="tooltip"
-                  title="Create a totally random alias"
-                  class="btn btn-success">Random Alias
-          </button>
-        </form>
+        <div class="btn-group" role="group">
+            <form method="post">
+                <input type="hidden" name="form-name" value="create-random-email">
+                <button data-toggle="tooltip"
+                        title="Create a totally random alias"
+                        class="btn btn-success">Random Alias
+                </button>
+            </form>
+            <button id="btnGroupDrop1" type="button" class="btn btn-success dropdown-toggle"
+                    data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+            </button>
+            <div class="dropdown-menu dropdown-menu-right" aria-labelledby="btnGroupDrop1">
+                <div class="">
+                    <form method="post">
+                        <input type="hidden" name="form-name" value="create-random-email">
+                        <input type="hidden" name="generator_scheme" value="{{ AliasGeneratorEnum.word.value }}">
+                        <button class="dropdown-item">By random words</button>
+                    </form>
+                </div>
+                <div class="">
+                    <form method="post">
+                        <input type="hidden" name="form-name" value="create-random-email">
+                        <input type="hidden" name="generator_scheme" value="{{ AliasGeneratorEnum.uuid.value }}">
+                        <button class="dropdown-item">By UUID</button>
+                    </form>
+                </div>
+            </div>
+        </div>
       </div>
     </div>
 
-
   </div>
 
   <div class="row">
@@ -60,7 +80,7 @@
       >
         <div class="card p-3 {% if alias_info.highlight %} highlight-row {% endif %}">
           <div>
-            <span class="clipboard mb-0"
+            <span class="clipboard cursor mb-0"
                 {% if gen_email.enabled %}
                   data-toggle="tooltip"
                   title="Copy to clipboard"
@@ -98,7 +118,7 @@
             <form method="post">
               <input type="hidden" name="form-name" value="switch-email-forwarding">
               <input type="hidden" name="gen-email-id" value="{{ gen_email.id }}">
-              <label class="custom-switch mt-2"
+              <label class="custom-switch cursor mt-2"
                      data-toggle="tooltip"
                   {% if gen_email.enabled %}
                      title="Block Alias"
@@ -115,8 +135,10 @@
                   {% endif %}
                      style="padding-left: 0px"
               >
+                <input type="hidden" name="alias" class="alias" value="{{ gen_email.email }}">
                 <input type="checkbox" class="custom-switch-input"
                     {{ "checked" if gen_email.enabled else "" }}>
+
                 <span class="custom-switch-indicator"></span>
               </label>
             </form>
@@ -143,6 +165,8 @@
               <form method="post">
                 <input type="hidden" name="form-name" value="delete-email">
                 <input type="hidden" name="gen-email-id" value="{{ gen_email.id }}">
+                <input type="hidden" name="alias" class="alias" value="{{ gen_email.email }}">
+
                 <span class="delete-email  btn btn-link btn-sm float-right">
                   Delete&nbsp; &nbsp;<i class="dropdown-icon fe fe-trash-2"></i>
                 </span>
@@ -248,9 +272,10 @@
 
 
     $(".delete-email").on("click", function (e) {
+      let alias = $(this).parent().find(".alias").val();
       notie.confirm({
-        text: "Once an alias is deleted, people/apps " +
-          "who used to contact you via this email address cannot reach you any more," +
+        text: `Once <b>${alias}</b> is deleted, people/apps ` +
+          "who used to contact you via this alias cannot reach you any more," +
           " please confirm.",
         cancelCallback: () => {
           // nothing to do
@@ -276,11 +301,12 @@
 
     $(".custom-switch-input").change(function (e) {
       var message = "";
+      let alias = $(this).parent().find(".alias").val();
 
       if (e.target.checked) {
-        message = `After this, you will start receiving email sent to this alias, please confirm.`;
+        message = `After this, you will start receiving email sent to <b>${alias}</b>, please confirm.`;
       } else {
-        message = `After this, you will stop receiving email sent to this alias, please confirm.`;
+        message = `After this, you will stop receiving email sent to <b>${alias}</b>, please confirm.`;
       }
 
       notie.confirm({

+ 28 - 0
app/dashboard/templates/dashboard/mfa_cancel.html

@@ -0,0 +1,28 @@
+{% extends 'default.html' %}
+{% set active_page = "setting" %}
+{% block title %}
+  Cancel MFA
+{% endblock %}
+
+
+{% block default_content %}
+  <div class="bg-white p-6" style="max-width: 60em; margin: auto">
+    <h1 class="h2">Multi Factor Authentication</h1>
+    <p>
+      To cancel MFA, please enter the 6-digit number in your TOTP application (Google Authenticator, Authy, etc) here.
+    </p>
+
+    <form method="post">
+      {{ otp_token_form.csrf_token }}
+
+      <div class="font-weight-bold mt-5">Token</div>
+      <div class="small-text">The 6-digit number displayed on your phone.</div>
+
+      {{ otp_token_form.token(class="form-control", placeholder="") }}
+      {{ render_field_errors(otp_token_form.token) }}
+      <button class="btn btn-lg btn-danger mt-2">Cancel MFA</button>
+    </form>
+
+
+  </div>
+{% endblock %}

+ 51 - 0
app/dashboard/templates/dashboard/mfa_setup.html

@@ -0,0 +1,51 @@
+{% extends 'default.html' %}
+{% set active_page = "setting" %}
+{% block title %}
+  MFA Setup
+{% endblock %}
+
+{% block head %}
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/qrious/4.0.2/qrious.min.js"></script>
+{% endblock %}
+
+{% block default_content %}
+  <div class="bg-white p-6" style="max-width: 60em; margin: auto">
+    <h1 class="h2">Multi Factor Authentication</h1>
+    <p>Please open a TOTP application (Google Authenticator, Authy, etc)
+      on your smartphone and scan the following QR Code:
+    </p>
+
+    <canvas id="qr"></canvas>
+
+    <script>
+      (function () {
+        var qr = new QRious({
+          element: document.getElementById('qr'),
+          value: '{{otp_uri}}'
+        });
+      })();
+    </script>
+
+    <div class="mt-3 mb-2">
+      Or you can use the manual entry with the following key:
+    </div>
+
+    <div class="mb-3 p-3" style="background-color: #eee">
+      {{ current_user.otp_secret }}
+    </div>
+
+
+    <form method="post">
+      {{ otp_token_form.csrf_token }}
+
+      <div class="font-weight-bold mt-5">Token</div>
+      <div class="small-text">Please enter the 6-digit number displayed on your phone.</div>
+
+      {{ otp_token_form.token(class="form-control", placeholder="") }}
+      {{ render_field_errors(otp_token_form.token) }}
+      <button class="btn btn-lg btn-success mt-2">Validate</button>
+    </form>
+
+
+  </div>
+{% endblock %}

+ 11 - 7
app/dashboard/templates/dashboard/pricing.html

@@ -14,18 +14,22 @@
     <div class="col-sm-6 col-lg-6">
       <div class="card">
         <div class="card-body text-center">
-          <div class="card-category">Premium</div>
-          <ul class="list-unstyled leading-loose">
-            <li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i> <em>Unlimited</em> Custom Alias</li>
+          <div class="h3">Premium</div>
+
+          <ul class="list-unstyled leading-loose mb-3">
+            <li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i> Unlimited Alias</li>
             <li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
-              Custom email domain
-              <span class="badge badge-success">In Beta</span>
-              <div class="small-text">Please contact us to try out this feature!</div>
+              Custom Domain
             </li>
             <li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
-              Support us
+              Directory (or Username)
+              <span class="badge badge-info">Coming Soon</span>
             </li>
           </ul>
+
+          <div class="small-text">More info on our <a href="https://simplelogin.io/pricing" target="_blank">Pricing
+            Page <i class="fe fe-external-link"></i>
+          </a></div>
         </div>
       </div>
     </div>

+ 32 - 1
app/dashboard/templates/dashboard/setting.html

@@ -1,7 +1,9 @@
 {% extends 'default.html' %}
 
+{% set active_page = "setting" %}
+
 {% block title %}
-  Setting
+  Settings
 {% endblock %}
 
 {% block default_content %}
@@ -48,8 +50,20 @@
       <button class="btn btn-primary">Update</button>
     </form>
 
+
     <hr>
+    <h3 class="mb-0">Multi-Factor Authentication (MFA)</h3>
+    <div class="small-text mb-3">
+      Secure your account with Multi-Factor Authentication.
+      This requires having applications like Google Authenticator, Authy, FreeOTP, etc.
+    </div>
+    {% if not current_user.enable_otp %}
+      <a href="{{ url_for('dashboard.mfa_setup') }}" class="btn btn-outline-primary">Enable</a>
+    {% else %}
+      <a href="{{ url_for('dashboard.mfa_cancel') }}" class="btn btn-outline-danger">Cancel MFA</a>
+    {% endif %}
 
+    <hr>
     <h3 class="mb-0">Change password</h3>
     <div class="small-text mb-3">You will receive an email containing instructions on how to change password.</div>
     <form method="post">
@@ -57,6 +71,23 @@
       <button class="btn btn-outline-primary">Change password</button>
     </form>
 
+    <hr>
+
+    <h3 class="mb-0">Random Alias</h3>
+    <div class="small-text mb-3">Choose how to create your email alias by default</div>
+    <form method="post" class="form-inline">
+      <input type="hidden" name="form-name" value="change-alias-generator">
+      <select class="custom-select mr-sm-2" name="alias-generator-scheme">
+        <option value="{{ AliasGeneratorEnum.word.value }}"
+            {% if current_user.alias_generator == AliasGeneratorEnum.word.value %} selected {% endif %} >Based on
+          Random {{ AliasGeneratorEnum.word.name.capitalize() }}</option>
+        <option value="{{ AliasGeneratorEnum.uuid.value }}"
+            {% if current_user.alias_generator == AliasGeneratorEnum.uuid.value %} selected {% endif %} >Based
+          on {{ AliasGeneratorEnum.uuid.name.upper() }}</option>
+      </select>
+      <button class="btn btn-outline-primary">Update Preference</button>
+    </form>
+
     <hr>
     <h3 class="mb-0">Export Data</h3>
     <div class="small-text mb-3">

+ 6 - 53
app/dashboard/views/custom_domain.py

@@ -3,9 +3,8 @@ from flask_login import login_required, current_user
 from flask_wtf import FlaskForm
 from wtforms import StringField, validators
 
-from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_SERVERS
+from app.config import EMAIL_SERVERS_WITH_PRIORITY
 from app.dashboard.base import dashboard_bp
-from app.dns_utils import get_mx_domains, get_spf_domain
 from app.extensions import db
 from app.models import CustomDomain
 
@@ -30,25 +29,7 @@ def custom_domain():
     errors = {}
 
     if request.method == "POST":
-        if request.form.get("form-name") == "delete":
-            custom_domain_id = request.form.get("custom-domain-id")
-            custom_domain = CustomDomain.get(custom_domain_id)
-
-            if not custom_domain:
-                flash("Unknown error. Refresh the page", "warning")
-                return redirect(url_for("dashboard.custom_domain"))
-            elif custom_domain.user_id != current_user.id:
-                flash("You cannot delete this domain", "warning")
-                return redirect(url_for("dashboard.custom_domain"))
-
-            name = custom_domain.domain
-            CustomDomain.delete(custom_domain_id)
-            db.session.commit()
-            flash(f"Domain {name} has been deleted successfully", "success")
-
-            return redirect(url_for("dashboard.custom_domain"))
-
-        elif request.form.get("form-name") == "create":
+        if request.form.get("form-name") == "create":
             if new_custom_domain_form.validate():
                 new_custom_domain = CustomDomain.create(
                     domain=new_custom_domain_form.domain.data, user_id=current_user.id
@@ -60,39 +41,11 @@ def custom_domain():
                     "success",
                 )
 
-                return redirect(url_for("dashboard.custom_domain"))
-        elif request.form.get("form-name") == "check-domain":
-            custom_domain_id = request.form.get("custom-domain-id")
-            custom_domain = CustomDomain.get(custom_domain_id)
-
-            if not custom_domain:
-                flash("Unknown error. Refresh the page", "warning")
-                return redirect(url_for("dashboard.custom_domain"))
-            elif custom_domain.user_id != current_user.id:
-                flash("You cannot delete this domain", "warning")
-                return redirect(url_for("dashboard.custom_domain"))
-            else:
-                spf_domains = get_spf_domain(custom_domain.domain)
-                for email_server in EMAIL_SERVERS:
-                    email_server = email_server[:-1]  # remove the trailing .
-                    if email_server not in spf_domains:
-                        flash(
-                            f"{email_server} is not included in your SPF record.",
-                            "warning",
-                        )
-
-                mx_domains = get_mx_domains(custom_domain.domain)
-                if mx_domains != EMAIL_SERVERS:
-                    errors[
-                        custom_domain.id
-                    ] = f"""Your DNS is not correctly set. The MX record we obtain is: {",".join(mx_domains)}"""
-                else:
-                    flash(
-                        "Your domain is verified. Now it can be used to create custom alias",
-                        "success",
+                return redirect(
+                    url_for(
+                        "dashboard.domain_detail", custom_domain_id=new_custom_domain.id
                     )
-                    custom_domain.verified = True
-                    db.session.commit()
+                )
 
     return render_template(
         "dashboard/custom_domain.html",

+ 106 - 0
app/dashboard/views/domain_detail.py

@@ -0,0 +1,106 @@
+from flask import render_template, request, redirect, url_for, flash
+from flask_login import login_required, current_user
+
+from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_SERVERS, DKIM_DNS_VALUE
+from app.dashboard.base import dashboard_bp
+from app.dns_utils import (
+    get_mx_domains,
+    get_spf_domain,
+    get_dkim_record,
+    get_txt_record,
+)
+from app.extensions import db
+from app.models import CustomDomain
+
+
+@dashboard_bp.route("/domains/<int:custom_domain_id>", methods=["GET", "POST"])
+@login_required
+def domain_detail(custom_domain_id):
+    # only premium user can see custom domain
+    if not current_user.is_premium():
+        flash("Only premium user can add custom domains", "warning")
+        return redirect(url_for("dashboard.index"))
+
+    custom_domain = CustomDomain.get(custom_domain_id)
+    if not custom_domain or custom_domain.user_id != current_user.id:
+        flash("You cannot see this page", "warning")
+        return redirect(url_for("dashboard.index"))
+
+    mx_ok = spf_ok = dkim_ok = True
+    mx_errors = spf_errors = dkim_errors = []
+
+    if request.method == "POST":
+        if request.form.get("form-name") == "check-mx":
+            mx_domains = get_mx_domains(custom_domain.domain)
+
+            if mx_domains != EMAIL_SERVERS:
+                mx_ok = False
+                mx_errors = get_mx_domains(custom_domain.domain, keep_priority=True)
+            else:
+                flash(
+                    "Your domain is verified. Now it can be used to create custom alias",
+                    "success",
+                )
+                custom_domain.verified = True
+                db.session.commit()
+                return redirect(
+                    url_for(
+                        "dashboard.domain_detail", custom_domain_id=custom_domain.id
+                    )
+                )
+        elif request.form.get("form-name") == "check-spf":
+            spf_domains = get_spf_domain(custom_domain.domain)
+            for email_server in EMAIL_SERVERS:
+                email_server = email_server[:-1]  # remove the trailing .
+                if email_server not in spf_domains:
+                    flash(
+                        f"{email_server} is not included in your SPF record.", "warning"
+                    )
+                    spf_ok = False
+
+            if spf_ok:
+                custom_domain.spf_verified = True
+                db.session.commit()
+                flash("The SPF is setup correctly", "success")
+                return redirect(
+                    url_for(
+                        "dashboard.domain_detail", custom_domain_id=custom_domain.id
+                    )
+                )
+            else:
+                spf_errors = get_txt_record(custom_domain.domain)
+
+        elif request.form.get("form-name") == "check-dkim":
+            dkim_record = get_dkim_record(custom_domain.domain)
+            correct_dkim_record = f"v=DKIM1; k=rsa; p={DKIM_DNS_VALUE}"
+            if dkim_record == correct_dkim_record:
+                flash("The DKIM is setup correctly.", "success")
+                custom_domain.dkim_verified = True
+                db.session.commit()
+
+                return redirect(
+                    url_for(
+                        "dashboard.domain_detail", custom_domain_id=custom_domain.id
+                    )
+                )
+            else:
+                dkim_ok = False
+                dkim_errors = get_txt_record(f"dkim._domainkey.{custom_domain.domain}")
+
+        elif request.form.get("form-name") == "delete":
+            name = custom_domain.domain
+            CustomDomain.delete(custom_domain_id)
+            db.session.commit()
+            flash(f"Domain {name} has been deleted successfully", "success")
+
+            return redirect(url_for("dashboard.custom_domain"))
+
+    spf_include_records = []
+    for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY:
+        spf_include_records.append(f"include:{email_server[:-1]}")
+
+    spf_record = f"v=spf1 {' '.join(spf_include_records)} -all"
+
+    dkim_record = f"v=DKIM1; k=rsa; p={DKIM_DNS_VALUE}"
+
+    return render_template("dashboard/domain_detail.html", **locals())

+ 17 - 2
app/dashboard/views/index.py

@@ -9,7 +9,14 @@ from app.config import HIGHLIGHT_GEN_EMAIL_ID
 from app.dashboard.base import dashboard_bp
 from app.extensions import db
 from app.log import LOG
-from app.models import GenEmail, ClientUser, ForwardEmail, ForwardEmailLog, DeletedAlias
+from app.models import (
+    GenEmail,
+    ClientUser,
+    ForwardEmail,
+    ForwardEmailLog,
+    DeletedAlias,
+    AliasGeneratorEnum,
+)
 
 
 @dataclass
@@ -57,7 +64,14 @@ def index():
 
         elif request.form.get("form-name") == "create-random-email":
             if current_user.can_create_new_alias():
-                gen_email = GenEmail.create_new_random(user_id=current_user.id)
+                scheme = int(
+                    request.form.get("generator_scheme") or current_user.alias_generator
+                )
+                if not scheme or not AliasGeneratorEnum.has_value(scheme):
+                    scheme = current_user.alias_generator
+                gen_email = GenEmail.create_new_random(
+                    user_id=current_user.id, scheme=scheme
+                )
                 db.session.commit()
 
                 LOG.d("generate new email %s for user %s", gen_email, current_user)
@@ -112,6 +126,7 @@ def index():
         aliases=get_alias_info(current_user.id, query, highlight_gen_email_id),
         highlight_gen_email_id=highlight_gen_email_id,
         query=query,
+        AliasGeneratorEnum=AliasGeneratorEnum,
     )
 
 

+ 37 - 0
app/dashboard/views/mfa_cancel.py

@@ -0,0 +1,37 @@
+import pyotp
+from flask import render_template, flash, redirect, url_for
+from flask_login import login_required, current_user
+from flask_wtf import FlaskForm
+from wtforms import StringField, validators
+
+from app.dashboard.base import dashboard_bp
+from app.extensions import db
+
+
+class OtpTokenForm(FlaskForm):
+    token = StringField("Token", validators=[validators.DataRequired()])
+
+
+@dashboard_bp.route("/mfa_cancel", methods=["GET", "POST"])
+@login_required
+def mfa_cancel():
+    if not current_user.enable_otp:
+        flash("you don't have MFA enabled", "warning")
+        return redirect(url_for("dashboard.index"))
+
+    otp_token_form = OtpTokenForm()
+    totp = pyotp.TOTP(current_user.otp_secret)
+
+    if otp_token_form.validate_on_submit():
+        token = otp_token_form.token.data
+
+        if totp.verify(token):
+            current_user.enable_otp = False
+            current_user.otp_secret = None
+            db.session.commit()
+            flash("MFA is now disabled", "warning")
+            return redirect(url_for("dashboard.index"))
+        else:
+            flash("Incorrect token", "warning")
+
+    return render_template("dashboard/mfa_cancel.html", otp_token_form=otp_token_form)

+ 49 - 0
app/dashboard/views/mfa_setup.py

@@ -0,0 +1,49 @@
+import pyotp
+from flask import render_template, flash, redirect, url_for
+from flask_login import login_required, current_user
+from flask_wtf import FlaskForm
+from wtforms import StringField, validators
+
+from app.dashboard.base import dashboard_bp
+from app.extensions import db
+from app.log import LOG
+
+
+class OtpTokenForm(FlaskForm):
+    token = StringField("Token", validators=[validators.DataRequired()])
+
+
+@dashboard_bp.route("/mfa_setup", methods=["GET", "POST"])
+@login_required
+def mfa_setup():
+    if current_user.enable_otp:
+        flash("you have already enabled MFA", "warning")
+        return redirect(url_for("dashboard.index"))
+
+    otp_token_form = OtpTokenForm()
+
+    if not current_user.otp_secret:
+        LOG.d("Generate otp_secret for user %s", current_user)
+        current_user.otp_secret = pyotp.random_base32()
+        db.session.commit()
+
+    totp = pyotp.TOTP(current_user.otp_secret)
+
+    if otp_token_form.validate_on_submit():
+        token = otp_token_form.token.data
+
+        if totp.verify(token):
+            current_user.enable_otp = True
+            db.session.commit()
+            flash("MFA has been activated", "success")
+            return redirect(url_for("dashboard.index"))
+        else:
+            flash("Incorrect token", "warning")
+
+    otp_uri = pyotp.totp.TOTP(current_user.otp_secret).provisioning_uri(
+        name=current_user.email, issuer_name="SimpleLogin"
+    )
+
+    return render_template(
+        "dashboard/mfa_setup.html", otp_token_form=otp_token_form, otp_uri=otp_uri
+    )

+ 9 - 0
app/dashboard/views/setting.py

@@ -23,6 +23,7 @@ from app.models import (
     DeletedAlias,
     CustomDomain,
     Client,
+    AliasGeneratorEnum,
 )
 from app.utils import random_string
 
@@ -121,6 +122,13 @@ def setting():
             logout_user()
             return redirect(url_for("auth.register"))
 
+        elif request.form.get("form-name") == "change-alias-generator":
+            scheme = int(request.form.get("alias-generator-scheme"))
+            if AliasGeneratorEnum.has_value(scheme):
+                current_user.alias_generator = scheme
+                db.session.commit()
+            flash("Your preference has been updated", "success")
+
         elif request.form.get("form-name") == "export-data":
             data = {
                 "email": current_user.email,
@@ -157,6 +165,7 @@ def setting():
         PlanEnum=PlanEnum,
         promo_form=promo_form,
         pending_email=pending_email,
+        AliasGeneratorEnum=AliasGeneratorEnum,
     )
 
 

+ 41 - 3
app/dns_utils.py

@@ -1,7 +1,9 @@
 import dns.resolver
 
 
-def get_mx_domains(hostname) -> [str]:
+def get_mx_domains(hostname, keep_priority=False) -> [str]:
+    """return list of (domain name). priority is also included if `keep_priority`
+    """
     try:
         answers = dns.resolver.query(hostname, "MX")
     except dns.resolver.NoAnswer:
@@ -11,8 +13,10 @@ def get_mx_domains(hostname) -> [str]:
 
     for a in answers:
         record = a.to_text()  # for ex '20 alt2.aspmx.l.google.com.'
-        r = record.split(" ")[1]  # alt2.aspmx.l.google.com.
-        ret.append(r)
+        if not keep_priority:
+            record = record.split(" ")[1]  # alt2.aspmx.l.google.com.
+
+        ret.append(record)
 
     return ret
 
@@ -40,3 +44,37 @@ def get_spf_domain(hostname) -> [str]:
                         ret.append(part[part.find(_include_spf) + len(_include_spf) :])
 
     return ret
+
+
+def get_txt_record(hostname) -> [str]:
+    try:
+        answers = dns.resolver.query(hostname, "TXT")
+    except dns.resolver.NoAnswer:
+        return []
+
+    ret = []
+
+    for a in answers:  # type: dns.rdtypes.ANY.TXT.TXT
+        for record in a.strings:
+            record = record.decode()  # record is bytes
+
+            ret.append(a)
+
+    return ret
+
+
+def get_dkim_record(hostname) -> str:
+    """query the dkim._domainkey.{hostname} record and returns its value"""
+    try:
+        answers = dns.resolver.query(f"dkim._domainkey.{hostname}", "TXT")
+    except dns.resolver.NoAnswer:
+        return ""
+
+    ret = []
+    for a in answers:  # type: dns.rdtypes.ANY.TXT.TXT
+        for record in a.strings:
+            record = record.decode()  # record is bytes
+
+            ret.append(record)
+
+    return "".join(ret)

+ 44 - 6
app/models.py

@@ -1,5 +1,6 @@
 import enum
 import random
+import uuid
 
 import arrow
 import bcrypt
@@ -83,6 +84,15 @@ class PlanEnum(enum.Enum):
     yearly = 3
 
 
+class AliasGeneratorEnum(enum.Enum):
+    word = 1  # aliases are generated based on random words
+    uuid = 2  # aliases are generated based on uuid
+
+    @classmethod
+    def has_value(cls, value: int) -> bool:
+        return value in set(item.value for item in cls)
+
+
 class User(db.Model, ModelMixin, UserMixin):
     __tablename__ = "users"
     email = db.Column(db.String(128), unique=True, nullable=False)
@@ -90,11 +100,22 @@ class User(db.Model, ModelMixin, UserMixin):
     password = db.Column(db.String(128), nullable=False)
     name = db.Column(db.String(128), nullable=False)
     is_admin = db.Column(db.Boolean, nullable=False, default=False)
+    alias_generator = db.Column(
+        db.Integer,
+        nullable=False,
+        default=AliasGeneratorEnum.word.value,
+        server_default=str(AliasGeneratorEnum.word.value),
+    )
 
     activated = db.Column(db.Boolean, default=False, nullable=False)
 
     profile_picture_id = db.Column(db.ForeignKey(File.id), nullable=True)
 
+    otp_secret = db.Column(db.String(16), nullable=True)
+    enable_otp = db.Column(
+        db.Boolean, nullable=False, default=False, server_default="0"
+    )
+
     profile_picture = db.relationship(File)
 
     @classmethod
@@ -362,9 +383,18 @@ class OauthToken(db.Model, ModelMixin):
         return self.expired < arrow.now()
 
 
-def generate_email() -> str:
-    """generate an email address that does not exist before"""
-    random_email = random_words() + "@" + EMAIL_DOMAIN
+def generate_email(
+    scheme: int = AliasGeneratorEnum.word.value, in_hex: bool = False
+) -> str:
+    """generate an email address that does not exist before
+    :param scheme: int, value of AliasGeneratorEnum, indicate how the email is generated
+    :type in_hex: bool, if the generate scheme is uuid, is hex favorable?
+    """
+    if scheme == AliasGeneratorEnum.uuid.value:
+        name = uuid.uuid4().hex if in_hex else uuid.uuid4().__str__()
+        random_email = name + "@" + EMAIL_DOMAIN
+    else:
+        random_email = random_words() + "@" + EMAIL_DOMAIN
 
     # check that the client does not exist yet
     if not GenEmail.get_by(email=random_email) and not DeletedAlias.get_by(
@@ -375,7 +405,7 @@ def generate_email() -> str:
 
     # Rerun the function
     LOG.warning("email %s already exists, generate a new email", random_email)
-    return generate_email()
+    return generate_email(scheme=scheme, in_hex=in_hex)
 
 
 class GenEmail(db.Model, ModelMixin):
@@ -408,9 +438,11 @@ class GenEmail(db.Model, ModelMixin):
         return GenEmail.create(user_id=user_id, email=email)
 
     @classmethod
-    def create_new_random(cls, user_id):
+    def create_new_random(
+        cls, user_id, scheme: int = AliasGeneratorEnum.word.value, in_hex: bool = False
+    ):
         """create a new random alias"""
-        random_email = generate_email()
+        random_email = generate_email(scheme=scheme, in_hex=in_hex)
         return GenEmail.create(user_id=user_id, email=random_email)
 
     def __repr__(self):
@@ -654,6 +686,12 @@ class CustomDomain(db.Model, ModelMixin):
     domain = db.Column(db.String(128), unique=True, nullable=False)
 
     verified = db.Column(db.Boolean, nullable=False, default=False)
+    dkim_verified = db.Column(
+        db.Boolean, nullable=False, default=False, server_default="0"
+    )
+    spf_verified = db.Column(
+        db.Boolean, nullable=False, default=False, server_default="0"
+    )
 
     def nb_alias(self):
         return GenEmail.filter_by(custom_domain_id=self.id).count()

+ 13 - 4
email_handler.py

@@ -47,7 +47,7 @@ from app.email_utils import (
 )
 from app.extensions import db
 from app.log import LOG
-from app.models import GenEmail, ForwardEmail, ForwardEmailLog
+from app.models import GenEmail, ForwardEmail, ForwardEmailLog, CustomDomain
 from app.utils import random_string
 from server import create_app
 
@@ -207,6 +207,12 @@ class MailHandler:
         forward_email = ForwardEmail.get_by(reply_email=reply_email)
         alias: str = forward_email.gen_email.email
 
+        # alias must end with EMAIL_DOMAIN or custom-domain
+        alias_domain = alias[alias.find("@") + 1 :]
+        if alias_domain != EMAIL_DOMAIN:
+            if not CustomDomain.get_by(domain=alias_domain):
+                return "550 alias unknown by SimpleLogin"
+
         user_email = forward_email.gen_email.user.email
         if envelope.mail_from != user_email:
             LOG.error(
@@ -248,10 +254,13 @@ class MailHandler:
             envelope.rcpt_options,
         )
 
-        # todo: add DKIM-Signature for custom domain
-        # add DKIM-Signature for non-custom-domain alias
-        if alias.endswith(EMAIL_DOMAIN):
+        if alias_domain == EMAIL_DOMAIN:
             add_dkim_signature(msg, EMAIL_DOMAIN)
+        # add DKIM-Signature for non-custom-domain alias
+        else:
+            custom_domain: CustomDomain = CustomDomain.get_by(domain=alias_domain)
+            if custom_domain.dkim_verified:
+                add_dkim_signature(msg, alias_domain)
 
         msg_raw = msg.as_string().encode()
         smtp.sendmail(

+ 6 - 0
local_data/dkim.pub.key

@@ -0,0 +1,6 @@
+-----BEGIN PUBLIC KEY-----
+MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCxhcKgFHz+HbZiuUhH7iGCVsaZ
+YQ7xzf64ui+09QFlSYzl7d28LVlr7nvM0+xDbwwsgu2D1vweklroWM5FjbfVtJX3
+HvSnNbwceX5du/m8RHelmX0/vLSfsEcnvdNjBmwl/gSIUb660pEp2yo6dUBDTzTD
+UBNoL6qmnnTNhriRoQIDAQAB
+-----END PUBLIC KEY-----

+ 1 - 1
migrations/alembic.ini

@@ -2,7 +2,7 @@
 
 [alembic]
 # template used to generate migration files
-# file_template = %%(rev)s_%%(slug)s
+file_template = %%(year)d_%%(month).2d%%(day).2d%%(hour).2d_%%(rev)s_%%(slug)s
 
 # set to 'true' to run the environment during
 # the 'revision' command, regardless of autogenerate

+ 29 - 0
migrations/versions/2019_122910_696e17c13b8b_.py

@@ -0,0 +1,29 @@
+"""empty message
+
+Revision ID: 696e17c13b8b
+Revises: e409f6214b2b
+Create Date: 2019-12-29 10:43:29.169736
+
+"""
+import sqlalchemy_utils
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '696e17c13b8b'
+down_revision = 'e409f6214b2b'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('custom_domain', sa.Column('spf_verified', sa.Boolean(), server_default='0', nullable=False))
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('custom_domain', 'spf_verified')
+    # ### end Alembic commands ###

+ 29 - 0
migrations/versions/2019_122910_e409f6214b2b_.py

@@ -0,0 +1,29 @@
+"""empty message
+
+Revision ID: e409f6214b2b
+Revises: d4e4488a0032
+Create Date: 2019-12-29 10:29:44.979846
+
+"""
+import sqlalchemy_utils
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'e409f6214b2b'
+down_revision = 'd4e4488a0032'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('users', sa.Column('alias_generator', sa.Integer(), server_default='1', nullable=False))
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('users', 'alias_generator')
+    # ### end Alembic commands ###

+ 29 - 0
migrations/versions/9e1b06b9df13_.py

@@ -0,0 +1,29 @@
+"""empty message
+
+Revision ID: 9e1b06b9df13
+Revises: 18e934d58f55
+Create Date: 2019-12-25 17:22:27.887481
+
+"""
+import sqlalchemy_utils
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '9e1b06b9df13'
+down_revision = '18e934d58f55'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('custom_domain', sa.Column('dkim_verified', sa.Boolean(), server_default='0', nullable=False))
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('custom_domain', 'dkim_verified')
+    # ### end Alembic commands ###

+ 31 - 0
migrations/versions/d4e4488a0032_.py

@@ -0,0 +1,31 @@
+"""empty message
+
+Revision ID: d4e4488a0032
+Revises: 9e1b06b9df13
+Create Date: 2019-12-27 15:19:11.060497
+
+"""
+import sqlalchemy_utils
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'd4e4488a0032'
+down_revision = '9e1b06b9df13'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('users', sa.Column('enable_otp', sa.Boolean(), server_default='0', nullable=False))
+    op.add_column('users', sa.Column('otp_secret', sa.String(length=16), nullable=True))
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('users', 'otp_secret')
+    op.drop_column('users', 'enable_otp')
+    # ### end Alembic commands ###

+ 2 - 1
requirements.in

@@ -31,4 +31,5 @@ dnspython
 coloredlogs
 pycryptodome
 phpserialize
-dkimpy
+dkimpy
+pyotp

+ 3 - 2
requirements.txt

@@ -70,6 +70,7 @@ pycparser==2.19           # via cffi
 pycryptodome==3.9.4
 pygments==2.4.2           # via ipython
 pyopenssl==19.0.0
+pyotp==2.3.0
 pyparsing==2.4.0          # via packaging
 pytest==4.6.3
 python-dateutil==2.8.0    # via alembic, arrow, botocore, strictyaml
@@ -81,10 +82,10 @@ requests-oauthlib==1.2.0
 requests==2.22.0          # via requests-oauthlib
 ruamel.yaml==0.15.97      # via strictyaml
 s3transfer==0.2.1         # via boto3
-sentry-sdk==0.13.2
+sentry-sdk==0.13.5
 six==1.12.0               # via bcrypt, cryptography, flask-cors, packaging, pip-tools, prompt-toolkit, pyopenssl, pytest, python-dateutil, sqlalchemy-utils, traitlets
 sqlalchemy-utils==0.33.11
-sqlalchemy==1.3.4         # via alembic, flask-sqlalchemy, sqlalchemy-utils
+sqlalchemy==1.3.12        # via alembic, flask-sqlalchemy, sqlalchemy-utils
 strictyaml==1.0.2         # via yacron
 traitlets==4.3.2          # via ipython
 unidecode==1.0.23

+ 1 - 0
server.py

@@ -97,6 +97,7 @@ def fake_data():
         password="password",
         activated=True,
         is_admin=True,
+        otp_secret="base32secret3232",
     )
     db.session.commit()
 

BIN
static/logo.png


+ 4 - 0
static/style.css

@@ -66,4 +66,8 @@ em {
 .copy-btn {
     font-size: 0.6rem;
     line-height: 0.75;
+}
+
+.cursor {
+    cursor: pointer;
 }

+ 1 - 1
templates/default.html

@@ -5,7 +5,7 @@
     {% include "header.html" %}
 
     <div class="my-2 my-md-2">
-      <div class="container">
+      <div class="container pt-3">
         {% block default_content %}
         {% endblock %}
       </div>

+ 0 - 24
templates/emails/welcome.html

@@ -4,29 +4,5 @@
   {{ render_text("Welcome " + name + "  🎉!") }}
   {{ render_text("I really appreciate you signing up for SimpleLogin, and I'm sure you'll love it when you see how *simple* it is to use.") }}
   {{ render_text("We built SimpleLogin to help people protecting their online identity, and I hope that we can achieve that for you.") }}
-
-  <!-- LINE -->
-  <!-- Set line color -->
-  <tr>
-    <td align="center" valign="top" style="border-collapse: collapse; border-spacing: 0; margin: 0; padding: 0; padding-left: 6.25%; padding-right: 6.25%; width: 87.5%;
-			padding-top: 25px;" class="line">
-      <hr
-          color="#E0E0E0" align="center" width="100%" size="1" noshade style="margin: 0; padding: 0;"/>
-    </td>
-  </tr>
-
-  <tr>
-    <td align="left" valign="top" style="border-collapse: collapse; border-spacing: 0; margin: 0; padding: 0; padding-left: 6.25%; padding-right: 6.25%; width: 87.5%; font-size: 24px; font-weight: 700; line-height: 200%;
-			padding-top: 25px;
-			color: #000000;
-			font-family: sans-serif;" class="paragraph">
-      Join our Community
-    </td>
-  </tr>
-
-  {{ render_text("Click the button 👇 to join our community on Spectrum and learn news about the SimpleLogin. We are community driven and we value feedbacks from you!") }}
-
-  {{ render_button("Join Spectrum Community", "https://spectrum.chat/simplelogin") }}
-
 {% endblock %}
 

+ 0 - 8
templates/emails/welcome.txt

@@ -7,11 +7,3 @@ We built SimpleLogin to help people protecting their online identity, and I hope
 Thanks.
 
 Son - SimpleLogin founder.
-
----
-
-Join our Community
-
-Open the link 👇 to join our community on Spectrum and learn news about the SimpleLogin. We are community driven and we value feedbacks from you!
-
-https://spectrum.chat/simplelogin

+ 1 - 15
templates/header.html

@@ -2,7 +2,7 @@
   <div class="container">
     <div class="d-flex">
       <a class="header-brand" href="{{ url_for('dashboard.index') }}">
-        <img src="/static/icon.svg" class="header-brand-img" alt="logo">
+        <img src="/static/logo.png" class="header-brand-img" alt="logo">
       </a>
 
       <div class="d-flex order-lg-2 ml-auto">
@@ -42,20 +42,6 @@
           </a>
 
           <div class="dropdown-menu dropdown-menu-right dropdown-menu-arrow">
-            <a class="dropdown-item" href="{{ url_for('dashboard.setting') }}">
-              <i class="dropdown-icon fe fe-settings"></i> Settings
-            </a>
-
-            <a class="dropdown-item" href="{{ url_for('dashboard.api_key') }}">
-              <i class="dropdown-icon fe fe-chrome"></i> API Key
-            </a>
-
-            {% if current_user.is_premium() %}
-              <a class="dropdown-item" href="{{ url_for('dashboard.custom_domain') }}">
-                <i class="dropdown-icon fe fe-server"></i> Custom Domains <span class="badge badge-info">Beta</span>
-              </a>
-            {% endif %}
-
             {% if current_user.is_premium() %}
               <a class="dropdown-item" href="{{ url_for('dashboard.billing') }}">
                 <i class="dropdown-icon fe fe-dollar-sign"></i> Billing

+ 23 - 0
templates/menu.html

@@ -7,6 +7,29 @@
     </a>
   </li>
 
+  <li class="nav-item">
+    <a href="{{ url_for('dashboard.setting') }}"
+       class="nav-link {{ 'active' if active_page == 'setting' }}">
+      <i class="fe fe-settings"></i>
+      Settings
+    </a>
+  </li>
+
+  <li class="nav-item">
+    <a href="{{ url_for('dashboard.api_key') }}"
+       class="nav-link {{ 'active' if active_page == 'api_key' }}">
+      <i class="fe fe-chrome"></i> API Key
+    </a>
+  </li>
+
+  <li class="nav-item">
+    <a href="{{ url_for('dashboard.custom_domain') }}"
+       class="nav-link {{ 'active' if active_page == 'custom_domain' }}">
+      <i class="fe fe-server"></i> Custom Domains
+      <span class="badge badge-success" style="font-size: .5rem; top: 5px">Premium</span>
+    </a>
+  </li>
+
   <!--
   <li class="nav-item">
     <a href="{{ url_for('discover.index') }}"

+ 2 - 2
templates/single.html

@@ -4,10 +4,10 @@
   <div class="page-single">
     <div class="container">
       <div class="row">
-        <div class="col col-login mx-auto">
+        <div class="col mx-auto" style="max-width: 50rem">
           <div class="text-center mb-6">
             <a href="https://simplelogin.io">
-              <img src="/static/icon.svg" class="h-6" alt="">
+              <img src="/static/logo.png" style="background-color: transparent; height: 40px">
             </a>
           </div>
 

+ 1 - 0
tests/env.test

@@ -11,6 +11,7 @@ ADMIN_EMAIL=to_fill
 MAX_NB_EMAIL_FREE_PLAN=3
 EMAIL_SERVERS_WITH_PRIORITY=[(10, "email.hostname.")]
 DKIM_PRIVATE_KEY_PATH=local_data/dkim.key
+DKIM_PUBLIC_KEY_PATH=local_data/dkim.pub.key
 
 # Database
 RESET_DB=true

+ 9 - 0
tests/test_models.py

@@ -1,4 +1,7 @@
+from uuid import UUID
+
 import arrow
+import pytest
 
 from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN
 from app.extensions import db
@@ -9,6 +12,12 @@ def test_generate_email(flask_client):
     email = generate_email()
     assert email.endswith("@" + EMAIL_DOMAIN)
 
+    with pytest.raises(ValueError):
+        UUID(email.split("@")[0], version=4)
+
+    email_uuid = generate_email(scheme=2)
+    assert UUID(email_uuid.split("@")[0], version=4)
+
 
 def test_profile_picture_url(flask_client):
     user = User.create(