소스 검색

Merge branch 'master' into multiple-mailboxes

# Conflicts:
#	app/dashboard/templates/dashboard/custom_alias.html
#	email_handler.py
#	templates/emails/com/newsletter/mobile-darkmode.html
Son NK 5 년 전
부모
커밋
362d101bab
41개의 변경된 파일610개의 추가작업 그리고 489개의 파일을 삭제
  1. 2 0
      README.md
  2. 17 0
      app/api/base.py
  3. 5 2
      app/api/views/apple.py
  4. 11 5
      app/api/views/auth.py
  5. 48 42
      app/auth/templates/auth/fido.html
  6. 26 24
      app/auth/templates/auth/mfa.html
  7. 3 0
      app/auth/views/fido.py
  8. 6 3
      app/auth/views/register.py
  9. 5 4
      app/dashboard/templates/dashboard/alias_log.html
  10. 68 66
      app/dashboard/templates/dashboard/billing.html
  11. 70 69
      app/dashboard/templates/dashboard/custom_alias.html
  12. 1 1
      app/dashboard/templates/dashboard/directory.html
  13. 7 7
      app/dashboard/templates/dashboard/domain_detail/dns.html
  14. 14 13
      app/dashboard/templates/dashboard/fido_cancel.html
  15. 42 40
      app/dashboard/templates/dashboard/fido_setup.html
  16. 8 6
      app/dashboard/templates/dashboard/index.html
  17. 13 11
      app/dashboard/templates/dashboard/lifetime_licence.html
  18. 16 15
      app/dashboard/templates/dashboard/mfa_cancel.html
  19. 30 31
      app/dashboard/templates/dashboard/mfa_setup.html
  20. 15 13
      app/dashboard/templates/dashboard/unsubscribe.html
  21. 8 0
      app/dashboard/views/domain_detail.py
  22. 8 4
      app/dashboard/views/index.py
  23. 2 2
      app/dashboard/views/mailbox.py
  24. 2 2
      app/dashboard/views/mailbox_detail.py
  25. 6 3
      app/dashboard/views/setting.py
  26. 10 10
      app/email_utils.py
  27. 47 44
      app/oauth/templates/oauth/authorize_nonlogin_user.html
  28. 5 6
      cron.py
  29. 27 18
      email_handler.py
  30. 3 2
      server.py
  31. 2 2
      static/assets/css/darkmode.css
  32. 1 14
      static/assets/js/core.js
  33. 40 0
      static/js/theme.js
  34. 8 4
      static/style.css
  35. 5 2
      templates/base.html
  36. 1 1
      templates/emails/base.html
  37. 1 1
      templates/emails/com/newsletter/mailbox.html
  38. 1 1
      templates/emails/com/newsletter/mailbox.txt
  39. 14 8
      templates/emails/com/newsletter/mobile-darkmode.html
  40. 1 2
      templates/footer.html
  41. 11 11
      tests/test_email_utils.py

+ 2 - 0
README.md

@@ -1262,5 +1262,7 @@ Thanks go to these wonderful people:
     <td align="center"><a href="https://github.com/NinhDinh"><img src="https://avatars2.githubusercontent.com/u/1419742?s=460&v=4" width="100px;" alt="Ninh Dinh"/><br /><sub><b>Ninh Dinh</b></sub></a><br /></td>
     <td align="center"><a href="https://github.com/ntung"><img src="https://avatars1.githubusercontent.com/u/663341?s=460&v=4" width="100px;" alt="Tung Nguyen V. N."/><br /><sub><b>Tung Nguyen V. N.</b></sub></a><br /></td>
     <td align="center"><a href="https://www.linkedin.com/in/nguyenkims/"><img src="https://simplelogin.io/about/me.jpeg" width="100px;" alt="Son Nguyen Kim"/><br /><sub><b>Son Nguyen Kim</b></sub></a><br /></td>
+    <td align="center"><a href="https://github.com/developStorm"><img src="https://avatars1.githubusercontent.com/u/59678453?s=460&u=3813d29a125b3edeb44019234672b704f7b9b76a&v=4" width="100px;" alt="Raymond Nook"/><br /><sub><b>Raymond Nook</b></sub></a><br /></td>
+    <td align="center"><a href="https://github.com/SibrenVasse"><img src="https://avatars1.githubusercontent.com/u/5833571?s=460&u=78aea62ffc215885a0319437fc629a7596ddea31&v=4" width="100px;" alt="Sibren Vasse"/><br /><sub><b>Sibren Vasse</b></sub></a><br /></td>
 </tr>
 </table>

+ 17 - 0
app/api/base.py

@@ -5,6 +5,7 @@ from flask import Blueprint, request, jsonify, g
 from flask_login import current_user
 
 from app.extensions import db
+from app.log import LOG
 from app.models import ApiKey
 
 api_bp = Blueprint(name="api", import_name=__name__, url_prefix="/api")
@@ -32,3 +33,19 @@ def require_api_auth(f):
         return f(*args, **kwargs)
 
     return decorated
+
+
+@api_bp.app_errorhandler(404)
+def not_found(e):
+    return jsonify(error="No such endpoint"), 404
+
+
+@api_bp.app_errorhandler(Exception)
+def internal_error(e):
+    LOG.exception(e)
+    return jsonify(error="Internal error"), 500
+
+
+@api_bp.app_errorhandler(405)
+def wrong_method(e):
+    return jsonify(error="Method not allowed"), 405

+ 5 - 2
app/api/views/apple.py

@@ -283,7 +283,7 @@ def apple_update_notification():
             db.session.commit()
             return jsonify(ok=True), 200
         else:
-            LOG.error(
+            LOG.warning(
                 "No existing AppleSub for original_transaction_id %s",
                 original_transaction_id,
             )
@@ -313,7 +313,6 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
         )
 
     data = r.json()
-    LOG.d("response from Apple %s", data)
     # data has the following format
     # {
     #     "status": 0,
@@ -490,6 +489,10 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
     #     "is_in_intro_offer_period": "false",
     # }
     transactions = data["receipt"]["in_app"]
+    if not transactions:
+        LOG.warning("Empty transactions in data %s", data)
+        return None
+
     latest_transaction = max(transactions, key=lambda t: int(t["expires_date_ms"]))
     original_transaction_id = latest_transaction["original_transaction_id"]
     expires_date = arrow.get(int(latest_transaction["expires_date_ms"]) / 1000)

+ 11 - 5
app/api/views/auth.py

@@ -12,8 +12,8 @@ from app.api.base import api_bp
 from app.config import FLASK_SECRET, DISABLE_REGISTRATION
 from app.dashboard.views.setting import send_reset_password_email
 from app.email_utils import (
-    can_be_used_as_personal_email,
-    email_already_used,
+    email_domain_can_be_used_as_mailbox,
+    personal_email_already_used,
     send_email,
     render,
 )
@@ -84,7 +84,9 @@ def auth_register():
 
     if DISABLE_REGISTRATION:
         return jsonify(error="registration is closed"), 400
-    if not can_be_used_as_personal_email(email) or email_already_used(email):
+    if not email_domain_can_be_used_as_mailbox(email) or personal_email_already_used(
+        email
+    ):
         return jsonify(error=f"cannot use {email} as personal inbox"), 400
 
     if not password or len(password) < 8:
@@ -236,7 +238,9 @@ def auth_facebook():
     if not user:
         if DISABLE_REGISTRATION:
             return jsonify(error="registration is closed"), 400
-        if not can_be_used_as_personal_email(email) or email_already_used(email):
+        if not email_domain_can_be_used_as_mailbox(
+            email
+        ) or personal_email_already_used(email):
             return jsonify(error=f"cannot use {email} as personal inbox"), 400
 
         LOG.d("create facebook user with %s", user_info)
@@ -288,7 +292,9 @@ def auth_google():
     if not user:
         if DISABLE_REGISTRATION:
             return jsonify(error="registration is closed"), 400
-        if not can_be_used_as_personal_email(email) or email_already_used(email):
+        if not email_domain_can_be_used_as_mailbox(
+            email
+        ) or personal_email_already_used(email):
             return jsonify(error=f"cannot use {email} as personal inbox"), 400
 
         LOG.d("create Google user with %s", user_info)

+ 48 - 42
app/auth/templates/auth/fido.html

@@ -11,56 +11,62 @@
 {% endblock %}
 
 {% block single_content %}
-  <div class="bg-white p-6" style="margin: auto">
+  <div class="card">
+    <div class="card-body">
 
-    <div class="mb-2">
-      Your account is protected with your security key (WebAuthn). <br><br>
-      Follow your browser's steps to continue the sign-in process.
-    </div>
-
-    <form id="formRegisterKey" method="post">
-      {{ fido_token_form.csrf_token }}
-      {{ fido_token_form.sk_assertion(class="form-control", placeholder="") }}
-    </form>
-    <div class="text-center">
-      <button id="btnVerifyKey" class="btn btn-success mt-2">Use your security key</button>
-    </div>
+      <div class="mb-2">
+        Your account is protected with your security key (WebAuthn). <br><br>
+        Follow your browser's steps to continue the sign-in process.
+      </div>
 
-    {% if enable_otp %}
-      <div class="text-center text-muted mb-6" style="margin-top: 1em;">
-        Don't have your key with you? <br> <a href="{{ url_for('auth.mfa') }}">Verify by One-Time Password</a>
+      <form id="formRegisterKey" method="post">
+        {{ fido_token_form.csrf_token }}
+        {{ fido_token_form.sk_assertion(class="form-control", placeholder="") }}
+      </form>
+      <div class="text-center">
+        <button id="btnVerifyKey" class="btn btn-success mt-2">Use your security key</button>
       </div>
-    {% endif %}
-    
-    <script>
-      async function verifyKey () {
-        $("#btnVerifyKey").prop('disabled', true);
-        $("#btnVerifyKey").text('Waiting for Security Key...');
 
-        const credentialRequestOptions = transformCredentialRequestOptions(
-          JSON.parse('{{webauthn_assertion_options|tojson|safe}}')
-        )
+      {% if enable_otp %}
+        <div class="text-center text-muted mb-6" style="margin-top: 1em;">
+          Don't have your key with you? <br> <a href="{{ url_for('auth.mfa') }}">Verify by One-Time Password</a>
+        </div>
+      {% endif %}
 
-        let assertion;
-        try {
-          assertion = await navigator.credentials.get({
-            publicKey: credentialRequestOptions
-          });
-        } catch (err) {
-          toastr.error("An error occurred when we trying to verify your key.");
-          $("#btnVerifyKey").prop('disabled', false);
-          $("#btnVerifyKey").text('Use your security key');
-          return console.error("Error when trying to get credential:", err);
-        }
+      <script>
+        async function verifyKey() {
+          $("#btnVerifyKey").prop('disabled', true);
+          $("#btnVerifyKey").text('Waiting for Security Key...');
 
-        const skAssertion = transformAssertionForServer(assertion);
-        $('#sk_assertion').val(JSON.stringify(skAssertion));
-        $('#formRegisterKey').submit();
-      }
+          const credentialRequestOptions = transformCredentialRequestOptions(
+            JSON.parse('{{webauthn_assertion_options|tojson|safe}}')
+          )
 
-      $("#btnVerifyKey").click(verifyKey);
-    </script>
+          let assertion;
+          try {
+            assertion = await navigator.credentials.get({
+              publicKey: credentialRequestOptions
+            });
+          } catch (err) {
+            toastr.error("An error occurred when we trying to verify your key.");
+            $("#btnVerifyKey").prop('disabled', false);
+            $("#btnVerifyKey").text('Use your security key');
+            return console.error("Error when trying to get credential:", err);
+          }
 
+          const skAssertion = transformAssertionForServer(assertion);
+          $('#sk_assertion').val(JSON.stringify(skAssertion));
+          $('#formRegisterKey').submit();
+        }
+
+        $("#btnVerifyKey").click(verifyKey);
+      </script>
+
+      {% if auto_activate %}
+        <script>$('document').ready(verifyKey());</script>
+      {% endif %}
+        
+    </div>
   </div>
 
 {% endblock %}

+ 26 - 24
app/auth/templates/auth/mfa.html

@@ -7,33 +7,35 @@
 
 
 {% block single_content %}
-  <div class="bg-white p-6" style="margin: auto">
+  <div class="card">
+    <div class="card-body p-6">
 
-    <div class="mb-2">
-      Your account is protected with multi-factor authentication (MFA). <br><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, MyDigiPassword, etc) here
+      <div class="mb-2">
+        Your account is protected with multi-factor authentication (MFA). <br><br>
+        To continue with the sign-in you need to provide the access code from your authenticator.
       </div>
 
-      {{ otp_token_form.token(class="form-control", autofocus="true") }}
-      {{ render_field_errors(otp_token_form.token) }}
-      <button class="btn btn-success mt-2">Validate</button>
-    </form>
-
-    {% if enable_fido %}
-      <div class="text-center text-muted mb-6" style="margin-top: 1em;">
-        Having trouble with your authenticator? <br> <a href="{{ url_for('auth.fido') }}">Verify by your security key</a>
-      </div>
-    {% endif %}
-
+      <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, MyDigiPassword, etc) here
+        </div>
+
+        {{ otp_token_form.token(class="form-control", autofocus="true") }}
+        {{ render_field_errors(otp_token_form.token) }}
+        <button class="btn btn-success mt-2">Validate</button>
+      </form>
+
+      {% if enable_fido %}
+        <div class="text-center text-muted mb-6" style="margin-top: 1em;">
+          Having trouble with your authenticator? <br> <a href="{{ url_for('auth.fido') }}">Verify by your security
+          key</a>
+        </div>
+      {% endif %}
+    </div>
   </div>
 
 {% endblock %}

+ 3 - 0
app/auth/views/fido.py

@@ -35,6 +35,7 @@ def fido():
         flash("Only user with security key linked should go to this page", "warning")
         return redirect(url_for("auth.login"))
 
+    auto_activate = True
     fido_token_form = FidoTokenForm()
 
     next_url = request.args.get("next")
@@ -69,6 +70,7 @@ def fido():
         except Exception as e:
             LOG.error(f"An error occurred in WebAuthn verification process: {e}")
             flash("Key verification failed.", "warning")
+            auto_activate = False
         else:
             user.fido_sign_count = new_sign_count
             db.session.commit()
@@ -101,4 +103,5 @@ def fido():
         fido_token_form=fido_token_form,
         webauthn_assertion_options=webauthn_assertion_options,
         enable_otp=user.enable_otp,
+        auto_activate=auto_activate,
     )

+ 6 - 3
app/auth/views/register.py

@@ -7,7 +7,10 @@ from app import email_utils, config
 from app.auth.base import auth_bp
 from app.auth.views.login_utils import get_referral
 from app.config import URL
-from app.email_utils import can_be_used_as_personal_email, email_already_used
+from app.email_utils import (
+    email_domain_can_be_used_as_mailbox,
+    personal_email_already_used,
+)
 from app.extensions import db
 from app.log import LOG
 from app.models import User, ActivationCode
@@ -37,10 +40,10 @@ def register():
 
     if form.validate_on_submit():
         email = form.email.data.strip().lower()
-        if not can_be_used_as_personal_email(email):
+        if not email_domain_can_be_used_as_mailbox(email):
             flash("You cannot use this email address as your personal inbox.", "error")
         else:
-            if email_already_used(email):
+            if personal_email_already_used(email):
                 flash(f"Email {email} already used", "error")
             else:
                 LOG.debug("create user %s", form.email.data)

+ 5 - 4
app/dashboard/templates/dashboard/alias_log.html

@@ -145,12 +145,13 @@
 
   <nav aria-label="Alias log navigation">
     <ul class="pagination">
-      <li class="page-item {% if page_id == 0 %}disabled{% endif %}">
-        <a class="page-link"
+      <li class="page-item">
+        <a class="btn btn-outline-secondary {% if page_id == 0 %}disabled{% endif %}"
            href="{{ url_for('dashboard.alias_log', alias_id=alias_id, page_id=page_id-1) }}">Previous</a>
       </li>
-      <li class="page-item {% if last_page %}disabled{% endif %}">
-        <a class="page-link" href="{{ url_for('dashboard.alias_log', alias_id=alias_id, page_id=page_id+1) }}">Next</a>
+      <li class="page-item">
+        <a class="btn btn-outline-secondary {% if last_page %}disabled{% endif %}"
+           href="{{ url_for('dashboard.alias_log', alias_id=alias_id, page_id=page_id+1) }}">Next</a>
       </li>
     </ul>
   </nav>

+ 68 - 66
app/dashboard/templates/dashboard/billing.html

@@ -8,77 +8,79 @@
 {% endblock %}
 
 {% block default_content %}
-  <div class="bg-white p-6" style="max-width: 60em; margin: auto">
-    <h1 class="h3 mb-5"> Billing </h1>
-
-    {% if sub.cancelled %}
-      <p>
-        You are on the <b>{{ sub.plan_name() }}</b> plan. <br>
-        You have canceled your subscription and it will end on {{ sub.next_bill_date.strftime("%Y-%m-%d") }}
-      </p>
-
-      <hr>
-      <p>
-        If you change your mind you can subscribe again to SimpleLogin but please note that this will be a completely
-        new subscription and
-        your payment method will be charged <b>immediately</b>.
-        <br>
-
-        We are going to send you an email by the end of the subscription so maybe you can upgrade at that time.
-        <br>
-        <a href="{{ url_for('dashboard.pricing') }}" class="btn btn-primary mt-2">Re-subscribe</a>
-      </p>
-
-    {% else %}
-      <p>
-        You are on the <b>{{ sub.plan_name() }}</b> plan. Thank you very much for supporting
-        SimpleLogin. 🙌 <br>
-        The next billing cycle starts at {{ sub.next_bill_date.strftime("%Y-%m-%d") }}.
-      </p>
-
-      <div class="mt-3">
-        Click here to update billing information on Paddle, our payment partner: <br>
-        <a class="btn btn-outline-success mt-2" href="{{ sub.update_url }}"> Update billing information </a>
-      </div>
-      <hr>
-      <div class="mt-6">
-        <h4>Change Plan</h4>
-        You can change the plan at any moment. <br>
-        Please note that the new billing cycle starts instantly
-        i.e. you will be charged <b>immediately</b> the annual fee when switching from monthly plan or vice-versa
-        <b>without pro rata computation </b>. <br>
-
-        To change the plan you can also cancel the current one and subscribe a new one <b>by the end</b> of this plan.
-
-        {% if sub.plan == PlanEnum.yearly %}
-          <form method="post">
-            <input type="hidden" name="form-name" value="change-monthly">
-            <button class="btn btn-outline-primary mt-2">Change to Monthly Plan</button>
-          </form>
-        {% else %}
-          <form method="post">
-            <input type="hidden" name="form-name" value="change-yearly">
-            <button class="btn btn-outline-primary mt-2">Change to Yearly Plan</button>
-          </form>
-        {% endif %}
-      </div>
+  <div class="card">
+    <div class="card-body">
+      <h1 class="h3 mb-5"> Billing </h1>
+
+      {% if sub.cancelled %}
+        <p>
+          You are on the <b>{{ sub.plan_name() }}</b> plan. <br>
+          You have canceled your subscription and it will end on {{ sub.next_bill_date.strftime("%Y-%m-%d") }}
+        </p>
+
+        <hr>
+        <p>
+          If you change your mind you can subscribe again to SimpleLogin but please note that this will be a completely
+          new subscription and
+          your payment method will be charged <b>immediately</b>.
+          <br>
+
+          We are going to send you an email by the end of the subscription so maybe you can upgrade at that time.
+          <br>
+          <a href="{{ url_for('dashboard.pricing') }}" class="btn btn-primary mt-2">Re-subscribe</a>
+        </p>
+
+      {% else %}
+        <p>
+          You are on the <b>{{ sub.plan_name() }}</b> plan. Thank you very much for supporting
+          SimpleLogin. 🙌 <br>
+          The next billing cycle starts at {{ sub.next_bill_date.strftime("%Y-%m-%d") }}.
+        </p>
+
+        <div class="mt-3">
+          Click here to update billing information on Paddle, our payment partner: <br>
+          <a class="btn btn-outline-success mt-2" href="{{ sub.update_url }}"> Update billing information </a>
+        </div>
+        <hr>
+        <div class="mt-6">
+          <h4>Change Plan</h4>
+          You can change the plan at any moment. <br>
+          Please note that the new billing cycle starts instantly
+          i.e. you will be charged <b>immediately</b> the annual fee when switching from monthly plan or vice-versa
+          <b>without pro rata computation </b>. <br>
+
+          To change the plan you can also cancel the current one and subscribe a new one <b>by the end</b> of this plan.
+
+          {% if sub.plan == PlanEnum.yearly %}
+            <form method="post">
+              <input type="hidden" name="form-name" value="change-monthly">
+              <button class="btn btn-outline-primary mt-2">Change to Monthly Plan</button>
+            </form>
+          {% else %}
+            <form method="post">
+              <input type="hidden" name="form-name" value="change-yearly">
+              <button class="btn btn-outline-primary mt-2">Change to Yearly Plan</button>
+            </form>
+          {% endif %}
+        </div>
+
+        <hr>
+
+        <div>
+          <h4>Cancel subscription</h4>
+          Don't want to protect your inbox anymore? <br>
 
-      <hr>
-
-      <div>
-        <h4>Cancel subscription</h4>
-        Don't want to protect your inbox anymore? <br>
-
-        <form method="post">
-          <input type="hidden" name="form-name" value="cancel">
+          <form method="post">
+            <input type="hidden" name="form-name" value="cancel">
 
-          <span class="cancel btn btn-outline-danger mt-2">
+            <span class="cancel btn btn-outline-danger mt-2">
             Cancel subscription <i class="fe fe-alert-triangle text-danger"></i>
           </span>
-        </form>
+          </form>
 
-      </div>
-    {% endif %}
+        </div>
+      {% endif %}
+    </div>
   </div>
 
 {% endblock %}

+ 70 - 69
app/dashboard/templates/dashboard/custom_alias.html

@@ -7,86 +7,87 @@
 {% endblock %}
 
 {% block default_content %}
-
-  <div class="bg-white p-6 mt-5" style="max-width: 55em; margin: auto">
-    <h1 class="h3 mb-5">New Email Alias</h1>
-
-    {% if  user_custom_domains|length == 0 and not DISABLE_ALIAS_SUFFIX %}
-      <div class="row">
-        <div class="col p-1">
-          <div class="alert alert-primary" role="alert">
-            You might notice a random word after the dot(<em>.</em>) in the alias.
-            This part is to avoid a person taking all the "nice" aliases like <b>hello@{{ FIRST_ALIAS_DOMAIN }}</b>,
-            <b>me@{{ FIRST_ALIAS_DOMAIN }}</b>, etc. <br>
-            If you add your own domain, this restriction is removed and you can fully customize the alias. <br>
+  <div class="card">
+    <div class="card-body">
+      <h1 class="h3">New Email Alias</h1>
+
+      {% if  user_custom_domains|length == 0 and not DISABLE_ALIAS_SUFFIX %}
+        <div class="row">
+          <div class="col p-1">
+            <div class="alert alert-primary" role="alert">
+              You might notice a random word after the dot(<em>.</em>) in the alias.
+              This part is to avoid a person taking all the "nice" aliases like
+              <b>hello@{{ FIRST_ALIAS_DOMAIN }}</b>,
+              <b>me@{{ FIRST_ALIAS_DOMAIN }}</b>, etc. <br>
+              If you add your own domain, this restriction is removed and you can fully customize the alias. <br>
+            </div>
           </div>
         </div>
-      </div>
-    {% endif %}
-
-    <form method="post">
-      <div class="row mb-2">
-        <div class="col-sm-6 mb-1 p-1" style="min-width: 4em">
-          <input name="prefix" class="form-control"
-                 id="prefix"
-                 type="text"
-                 pattern="[0-9a-z-_]{1,}"
-                 title="Only lowercase letter, number, dash (-), underscore (_) can be used in alias prefix."
-                 placeholder="email alias, for example newsletter-123_xyz"
-                 autofocus required>
-          <div class="small-text">
-            Only lowercase letter, number, dash (-), underscore (_) can be used.
+      {% endif %}
+
+      <form method="post">
+        <div class="row mb-2">
+          <div class="col-sm-6 mb-1 p-1" style="min-width: 4em">
+            <input name="prefix" class="form-control"
+                   id="prefix"
+                   type="text"
+                   pattern="[0-9a-z-_]{1,}"
+                   title="Only lowercase letter, number, dash (-), underscore (_) can be used in alias prefix."
+                   placeholder="email alias, for example newsletter-123_xyz"
+                   autofocus required>
+            <div class="small-text">
+              Only lowercase letter, number, dash (-), underscore (_) can be used.
+            </div>
           </div>
-        </div>
 
 
-        <div class="col-sm-6 p-1">
-          <select class="form-control" name="suffix">
-            {% for suffix in suffixes %}
-              <option value="{{ suffix[2] }}">
-                {% if suffix[0] %}
-                  {{ suffix[1] }} (your domain)
-                {% else %}
-                  {{ suffix[1] }}
-                {% endif %}
-              </option>
-            {% endfor %}
-          </select>
-        </div>
-      </div>
-
-      <div class="row mb-2">
-        <div class="col p-1">
-          <select class="form-control custom-select selectpicker" id="mailboxes" multiple name="mailboxes">
-            {% for mailbox in mailboxes %}
-              <option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}
-                      selected {% endif %}>
-                {{ mailbox.email }}
-              </option>
-            {% endfor %}
-          </select>
-          <div class="small-text">
-            The mailbox(es) that owns this alias.
+          <div class="col-sm-6 p-1">
+            <select class="form-control" name="suffix">
+              {% for suffix in suffixes %}
+                <option value="{{ suffix[2] }}">
+                  {% if suffix[0] %}
+                    {{ suffix[1] }} (your domain)
+                  {% else %}
+                    {{ suffix[1] }}
+                  {% endif %}
+                </option>
+              {% endfor %}
+            </select>
           </div>
         </div>
-      </div>
-
-      <div class="row mb-2">
-        <div class="col p-1">
-        <textarea name="note"
-                  class="form-control"
-                  rows="3"
-                  placeholder="Note, can be anything to help you remember WHY you create this alias. This field is optional."></textarea>
+
+        <div class="row mb-2">
+          <div class="col p-1">
+            <select class="form-control custom-select selectpicker" id="mailboxes" multiple name="mailboxes">
+              {% for mailbox in mailboxes %}
+                <option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}
+                        selected {% endif %}>
+                  {{ mailbox.email }}
+                </option>
+              {% endfor %}
+            </select>
+            <div class="small-text">
+              The mailbox(es) that owns this alias.
+            </div>
+          </div>
         </div>
-      </div>
 
+        <div class="row mb-2">
+          <div class="col p-1">
+            <textarea name="note"
+                      class="form-control"
+                      rows="3"
+                      placeholder="Note, can be anything to help you remember WHY you create this alias. This field is optional."></textarea>
+          </div>
+        </div>
 
-      <div class="row">
-        <div class="col p-1">
-          <span id="submit" class="btn btn-primary mt-1">Create</span>
+        <div class="row">
+          <div class="col p-1">
+            <span id="submit" class="btn btn-primary mt-1">Create</span>
+          </div>
         </div>
-      </div>
-    </form>
+      </form>
+    </div>
   </div>
 
 {% endblock %}

+ 1 - 1
app/dashboard/templates/dashboard/directory.html

@@ -32,7 +32,7 @@
           2️⃣ Quickly use one of the following formats to create an alias on-the-fly <b>without creating this alias
           beforehand</b>
         </div>
-        <div class="pl-3 py-2 bg-white">
+        <div class="pl-3 py-2 bg-secondary">
           <em>my_dir/<b>anything</b>@{{ FIRST_ALIAS_DOMAIN }}</em> or <br>
           <em>my_dir+<b>anything</b>@{{ FIRST_ALIAS_DOMAIN }}</em> or <br>
           <em>my_dir#<b>anything</b>@{{ FIRST_ALIAS_DOMAIN }}</em> <br>

+ 7 - 7
app/dashboard/templates/dashboard/domain_detail/dns.html

@@ -35,7 +35,7 @@
       </div>
 
       {% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}
-        <div class="mb-3 p-3 bg-secondary">
+        <div class="mb-3 p-3 dns-record">
           Record: MX <br>
           Domain: {{ custom_domain.domain }} or @ <br>
           Priority: {{ priority }} <br>
@@ -62,7 +62,7 @@
       {% 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 bg-secondary">
+          <div class="mb-3 p-3 dns-record">
             {% if not mx_errors %}
               (Empty)
             {% endif %}
@@ -97,7 +97,7 @@
 
       <div class="mb-2">Add the following TXT DNS record to your domain.</div>
 
-      <div class="mb-2 p-3 bg-secondary">
+      <div class="mb-2 p-3 dns-record">
         Record: TXT <br>
         Domain: {{ custom_domain.domain }} or @ <br>
         Value:
@@ -125,7 +125,7 @@
       {% 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 bg-secondary">
+          <div class="mb-3 p-3 dns-record">
             {% if not spf_errors %}
               (Empty)
             {% endif %}
@@ -162,7 +162,7 @@
 
       <div class="mb-2">Add the following CNAME DNS record to your domain.</div>
 
-      <div class="mb-2 p-3 bg-secondary">
+      <div class="mb-2 p-3 dns-record">
         Record: CNAME <br>
         Domain: <em data-toggle="tooltip"
                     title="Click to copy"
@@ -197,7 +197,7 @@
             The CNAME record we obtain for
             <em>dkim._domainkey.{{ custom_domain.domain }}</em> is:
 
-            <div class="mb-3 p-3 bg-secondary">
+            <div class="mb-3 p-3 dns-record">
               {% for r in dkim_errors %}
                 {{ r }} <br>
               {% endfor %}
@@ -231,7 +231,7 @@
 
       <div class="mb-2">Add the following TXT DNS record to your domain.</div>
 
-      <div class="mb-2 p-3 bg-secondary">
+      <div class="mb-2 p-3 dns-record">
         Record: TXT <br>
         Domain: <em data-toggle="tooltip"
                     title="Click to copy"

+ 14 - 13
app/dashboard/templates/dashboard/fido_cancel.html

@@ -6,22 +6,23 @@
 
 
 {% block default_content %}
-  <div class="bg-white p-6" style="max-width: 60em; margin: auto">
-    <h1 class="h2">Unlink Your Security Key</h1>
-    <p>
-      Please enter the password of your account so that we can ensure it's you.
-    </p>
+  <div class="card">
+    <div class="card-body">
+      <h1 class="h2">Unlink Your Security Key</h1>
+      <p>
+        Please enter the password of your account so that we can ensure it's you.
+      </p>
 
-    <form method="post">
-      {{ password_check_form.csrf_token }}
+      <form method="post">
+        {{ password_check_form.csrf_token }}
 
-      <div class="font-weight-bold mt-5">Password</div>
-
-      {{ password_check_form.password(class="form-control", autofocus="true") }}
-      {{ render_field_errors(password_check_form.password) }}
-      <button class="btn btn-lg btn-danger mt-2">Unlink Key</button>
-    </form>
+        <div class="font-weight-bold mt-5">Password</div>
 
+        {{ password_check_form.password(class="form-control", autofocus="true") }}
+        {{ render_field_errors(password_check_form.password) }}
+        <button class="btn btn-lg btn-danger mt-2">Unlink Key</button>
+      </form>
 
+    </div>
   </div>
 {% endblock %}

+ 42 - 40
app/dashboard/templates/dashboard/fido_setup.html

@@ -11,48 +11,50 @@
 {% endblock %}
 
 {% block default_content %}
-  <div class="bg-white p-6" style="max-width: 60em; margin: auto">
-    <h1 class="h2 text-center">Register Your Security Key</h1>
-    <p class="text-center">Follow your browser's steps to register your security key with SimpleLogin</p>
-
-    <form id="formRegisterKey" method="post">
-      {{ fido_token_form.csrf_token }}
-      {{ fido_token_form.sk_assertion(class="form-control", placeholder="") }}
-    </form>
-    <div class="text-center">
-      <button id="btnRegisterKey" class="btn btn-lg btn-primary mt-2">Register Key</button>
-    </div>
-
-    <script>
-      async function registerKey () {
-        $("#btnRegisterKey").prop('disabled', true);
-        $("#btnRegisterKey").text('Waiting for Security Key...');
-
-        const pkCredentialCreateOptions = transformCredentialCreateOptions(
-          JSON.parse('{{credential_create_options|tojson|safe}}')
-        )
-
-        let credential
-        try {
-          credential = await navigator.credentials.create({
-            publicKey: pkCredentialCreateOptions
-          });
-        } catch (err) {
-          toastr.error("An error occurred when we trying to register your key.");
-          $("#btnRegisterKey").prop('disabled', false);
-          $("#btnRegisterKey").text('Register Key');
-          return console.error("Error when trying to create credential:", err);
+  <div class="card">
+    <div class="card-body">
+      <h1 class="h2 text-center">Register Your Security Key</h1>
+      <p class="text-center">Follow your browser's steps to register your security key with SimpleLogin</p>
+
+      <form id="formRegisterKey" method="post">
+        {{ fido_token_form.csrf_token }}
+        {{ fido_token_form.sk_assertion(class="form-control", placeholder="") }}
+      </form>
+      <div class="text-center">
+        <button id="btnRegisterKey" class="btn btn-lg btn-primary mt-2">Register Key</button>
+      </div>
+
+      <script>
+        async function registerKey() {
+          $("#btnRegisterKey").prop('disabled', true);
+          $("#btnRegisterKey").text('Waiting for Security Key...');
+
+          const pkCredentialCreateOptions = transformCredentialCreateOptions(
+            JSON.parse('{{credential_create_options|tojson|safe}}')
+          )
+
+          let credential
+          try {
+            credential = await navigator.credentials.create({
+              publicKey: pkCredentialCreateOptions
+            });
+          } catch (err) {
+            toastr.error("An error occurred when we trying to register your key.");
+            $("#btnRegisterKey").prop('disabled', false);
+            $("#btnRegisterKey").text('Register Key');
+            return console.error("Error when trying to create credential:", err);
+          }
+
+          const skAssertion = transformNewAssertionForServer(credential);
+
+          $('#sk_assertion').val(JSON.stringify(skAssertion));
+          $('#formRegisterKey').submit();
         }
 
-        const skAssertion = transformNewAssertionForServer(credential);
-
-        $('#sk_assertion').val(JSON.stringify(skAssertion));
-        $('#formRegisterKey').submit();
-      }
-
-      $("#btnRegisterKey").click(registerKey);
-      $('document').ready(registerKey());
-    </script>
+        $("#btnRegisterKey").click(registerKey);
+        $('document').ready(registerKey());
+      </script>
 
+    </div>
   </div>
 {% endblock %}

+ 8 - 6
app/dashboard/templates/dashboard/index.html

@@ -400,13 +400,15 @@
     <div class="col">
       <nav aria-label="Alias navigation">
         <ul class="pagination">
-          <li class="page-item {% if page == 0 %}disabled{% endif %}">
-            <a class="page-link"
-               href="{{ url_for('dashboard.index', page=page-1, query=query, sort=sort, filter=filter) }}">Previous</a>
+          <li class="page-item">
+            <a class="btn btn-outline-secondary {% if page == 0 %}disabled{% endif %}"
+               href="{{ url_for('dashboard.index', page=page-1, query=query, sort=sort, filter=filter) }}">
+              Previous</a>
           </li>
-          <li class="page-item {% if last_page %}disabled{% endif %}">
-            <a class="page-link"
-               href="{{ url_for('dashboard.index', page=page+1, query=query, sort=sort, filter=filter) }}">Next</a>
+          <li class="page-item">
+            <a class="btn btn-outline-secondary {% if last_page %}disabled{% endif %}"
+               href="{{ url_for('dashboard.index', page=page+1, query=query, sort=sort, filter=filter) }}">
+              Next</a>
           </li>
         </ul>
       </nav>

+ 13 - 11
app/dashboard/templates/dashboard/lifetime_licence.html

@@ -7,20 +7,22 @@
 {% endblock %}
 
 {% block default_content %}
-  <div class="bg-white p-6" style="max-width: 60em; margin: auto">
-    <h1 class="h2">Lifetime Licence</h1>
+  <div class="row">
+    <div class="col">
+      <h1 class="h3">Lifetime Licence</h1>
 
-    <div class="mb-4">
-      If you have a lifetime licence, please paste it here. <br>
-    </div>
+      <div class="mb-4">
+        If you have a lifetime licence, please paste it here. <br>
+      </div>
 
-    <form method="post">
-      {{ coupon_form.csrf_token }}
+      <form method="post">
+        {{ coupon_form.csrf_token }}
 
-      {{ coupon_form.code(class="form-control", placeholder="Licence Code") }}
-      {{ render_field_errors(coupon_form.code) }}
-      <button class="btn btn-success mt-2">Apply</button>
-    </form>
+        {{ coupon_form.code(class="form-control", placeholder="Licence Code") }}
+        {{ render_field_errors(coupon_form.code) }}
+        <button class="btn btn-success mt-2">Apply</button>
+      </form>
+    </div>
   </div>
 
 {% endblock %}

+ 16 - 15
app/dashboard/templates/dashboard/mfa_cancel.html

@@ -6,24 +6,25 @@
 
 
 {% 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, MyDigiPassword, etc) here.
-    </p>
+  <div class="card">
+    <div class="card-body">
+      <h1 class="h2">Multi Factor Authentication</h1>
+      <p>
+        To cancel MFA, please enter the 6-digit number in your TOTP application
+        (Google Authenticator, Authy, MyDigiPassword, etc) here.
+      </p>
 
-    <form method="post">
-      {{ otp_token_form.csrf_token }}
+      <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", autofocus="true") }}
-      {{ render_field_errors(otp_token_form.token) }}
-      <button class="btn btn-lg btn-danger mt-2">Cancel MFA</button>
-    </form>
+        <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", autofocus="true") }}
+        {{ render_field_errors(otp_token_form.token) }}
+        <button class="btn btn-lg btn-danger mt-2">Cancel MFA</button>
+      </form>
+    </div>
 
   </div>
 {% endblock %}

+ 30 - 31
app/dashboard/templates/dashboard/mfa_setup.html

@@ -9,43 +9,42 @@
 {% 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, MyDigiPassword, 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="card">
+    <div class="card-body">
+      <h1 class="h3">Multi Factor Authentication</h1>
+      <p>Please open a TOTP application (Google Authenticator, Authy, MyDigiPassword, etc)
+        on your smartphone and scan the following QR Code:
+      </p>
 
-    <div class="mb-3 p-3" style="background-color: #eee">
-      {{ current_user.otp_secret }}
-    </div>
+      <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>
 
-    <form method="post">
-      {{ otp_token_form.csrf_token }}
+      <input class="form-control" disabled value="{{ current_user.otp_secret }}">
 
-      <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>
+      <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>
   </div>
 {% endblock %}

+ 15 - 13
app/dashboard/templates/dashboard/unsubscribe.html

@@ -8,20 +8,22 @@
 
 {% block default_content %}
 
-  <div class="col-md-6 offset-md-3 text-center bg-white p-3 mt-5">
-    <h1 class="h3">
-      Block alias
-    </h1>
-    <p>
-      You are about to block the alias <a href="mailto:{{alias}}">{{alias}}</a>
-    </p>
-    <p>
-      After this, you will stop receiving all emails sent to this alias, please confirm.
-    </p>
+  <div class="card">
+    <div class="card-body">
+      <h1 class="h3">
+        Block alias
+      </h1>
+      <p>
+        You are about to block the alias <a href="mailto:{{ alias }}">{{ alias }}</a>
+      </p>
+      <p>
+        After this, you will stop receiving all emails sent to this alias, please confirm.
+      </p>
 
-    <form method="post">
-      <button class="btn btn-warning">Confirm</button>
-    </form>
+      <form method="post">
+        <button class="btn btn-warning">Confirm</button>
+      </form>
+    </div>
   </div>
 
 {% endblock %}

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

@@ -37,6 +37,8 @@ def domain_detail_dns(custom_domain_id):
 
             if sorted(mx_domains) != sorted(EMAIL_SERVERS_WITH_PRIORITY):
                 flash("The MX record is not correctly set", "warning")
+                custom_domain.verified = False
+                db.session.commit()
                 mx_ok = False
                 # build mx_errors to show to user
                 mx_errors = [
@@ -66,6 +68,8 @@ def domain_detail_dns(custom_domain_id):
                     )
                 )
             else:
+                custom_domain.spf_verified = False
+                db.session.commit()
                 flash(
                     f"SPF: {EMAIL_DOMAIN} is not included in your SPF record.",
                     "warning",
@@ -86,6 +90,8 @@ def domain_detail_dns(custom_domain_id):
                     )
                 )
             else:
+                custom_domain.dkim_verified = False
+                db.session.commit()
                 flash("DKIM: the CNAME record is not correctly set", "warning")
                 dkim_ok = False
                 dkim_errors = [dkim_record or "[Empty]"]
@@ -102,6 +108,8 @@ def domain_detail_dns(custom_domain_id):
                     )
                 )
             else:
+                custom_domain.dmarc_verified = False
+                db.session.commit()
                 flash(
                     f"DMARC: The TXT record is not correctly set", "warning",
                 )

+ 8 - 4
app/dashboard/views/index.py

@@ -1,11 +1,11 @@
 from dataclasses import dataclass
-
 from flask import render_template, request, redirect, url_for, flash
 from flask_login import login_required, current_user
 from sqlalchemy.orm import joinedload
 
 from app import alias_utils
 from app.api.serializer import get_alias_infos_with_pagination_v2
+from app.config import PAGE_LIMIT
 from app.dashboard.base import dashboard_bp
 from app.extensions import db
 from app.log import LOG
@@ -140,18 +140,22 @@ def index():
 
     stats = get_stats(current_user)
 
+    alias_infos = get_alias_infos_with_pagination_v2(
+        current_user, page, query, sort, alias_filter
+    )
+    last_page = len(alias_infos) < PAGE_LIMIT
+
     return render_template(
         "dashboard/index.html",
         client_users=client_users,
-        alias_infos=get_alias_infos_with_pagination_v2(
-            current_user, page, query, sort, alias_filter
-        ),
+        alias_infos=alias_infos,
         highlight_alias_id=highlight_alias_id,
         query=query,
         AliasGeneratorEnum=AliasGeneratorEnum,
         mailboxes=mailboxes,
         show_intro=show_intro,
         page=page,
+        last_page=last_page,
         sort=sort,
         filter=alias_filter,
         stats=stats,

+ 2 - 2
app/dashboard/views/mailbox.py

@@ -8,7 +8,7 @@ from wtforms.fields.html5 import EmailField
 from app.config import EMAIL_DOMAIN, ALIAS_DOMAINS, MAILBOX_SECRET, URL
 from app.dashboard.base import dashboard_bp
 from app.email_utils import (
-    can_be_used_as_personal_email,
+    email_domain_can_be_used_as_mailbox,
     mailbox_already_used,
     render,
     send_email,
@@ -86,7 +86,7 @@ def mailbox_route():
 
                 if mailbox_already_used(mailbox_email, current_user):
                     flash(f"{mailbox_email} already used", "error")
-                elif not can_be_used_as_personal_email(mailbox_email):
+                elif not email_domain_can_be_used_as_mailbox(mailbox_email):
                     flash(f"You cannot use {mailbox_email}.", "error")
                 else:
                     new_mailbox = Mailbox.create(

+ 2 - 2
app/dashboard/views/mailbox_detail.py

@@ -10,7 +10,7 @@ from wtforms.fields.html5 import EmailField
 from app.config import ENFORCE_SPF, MAILBOX_SECRET
 from app.config import URL
 from app.dashboard.base import dashboard_bp
-from app.email_utils import can_be_used_as_personal_email
+from app.email_utils import email_domain_can_be_used_as_mailbox
 from app.email_utils import mailbox_already_used, render, send_email
 from app.extensions import db
 from app.log import LOG
@@ -54,7 +54,7 @@ def mailbox_detail_route(mailbox_id):
                     or DeletedAlias.get_by(email=new_email)
                 ):
                     flash(f"Email {new_email} already used", "error")
-                elif not can_be_used_as_personal_email(new_email):
+                elif not email_domain_can_be_used_as_mailbox(new_email):
                     flash("You cannot use this email address as your mailbox", "error")
                 else:
                     mailbox.new_email = new_email

+ 6 - 3
app/dashboard/views/setting.py

@@ -12,7 +12,10 @@ from wtforms.fields.html5 import EmailField
 from app import s3, email_utils
 from app.config import URL
 from app.dashboard.base import dashboard_bp
-from app.email_utils import can_be_used_as_personal_email, email_already_used
+from app.email_utils import (
+    email_domain_can_be_used_as_mailbox,
+    personal_email_already_used,
+)
 from app.extensions import db
 from app.log import LOG
 from app.models import (
@@ -70,12 +73,12 @@ def setting():
 
                     # check if this email is not already used
                     if (
-                        email_already_used(new_email)
+                        personal_email_already_used(new_email)
                         or Alias.get_by(email=new_email)
                         or DeletedAlias.get_by(email=new_email)
                     ):
                         flash(f"Email {new_email} already used", "error")
-                    elif not can_be_used_as_personal_email(new_email):
+                    elif not email_domain_can_be_used_as_mailbox(new_email):
                         flash(
                             "You cannot use this email address as your personal inbox.",
                             "error",

+ 10 - 10
app/email_utils.py

@@ -346,10 +346,11 @@ def email_belongs_to_alias_domains(address: str) -> bool:
     return False
 
 
-def can_be_used_as_personal_email(email: str) -> bool:
-    """return True if an email can be used as a personal email. Currently the only condition is email domain is not
+def email_domain_can_be_used_as_mailbox(email: str) -> bool:
+    """return True if an email can be used as a personal email. An email domain can be used if it is not
     - one of ALIAS_DOMAINS
     - one of custom domains
+    - disposable domain
     """
     domain = get_email_domain_part(email)
     if not domain:
@@ -402,17 +403,12 @@ def get_mx_domain_list(domain) -> [str]:
     return [d[:-1] for _, d in priority_domains]
 
 
-def email_already_used(email: str) -> bool:
-    """test if an email can be used when:
-    - user signs up
-    - add a new mailbox
+def personal_email_already_used(email: str) -> bool:
+    """test if an email can be used as user email
     """
     if User.get_by(email=email):
         return True
 
-    if Mailbox.get_by(email=email):
-        return True
-
     return False
 
 
@@ -503,7 +499,11 @@ def parseaddr_unicode(addr) -> (str, str):
         name = name.strip()
         decoded_string, charset = decode_header(name)[0]
         if charset is not None:
-            name = decoded_string.decode(charset)
+            try:
+                name = decoded_string.decode(charset)
+            except UnicodeDecodeError:
+                LOG.warning("Cannot decode addr name %s", name)
+                name = ""
         else:
             name = decoded_string
 

+ 47 - 44
app/oauth/templates/oauth/authorize_nonlogin_user.html

@@ -1,61 +1,64 @@
 {% extends "base.html" %}
 
 {% block content %}
-  <div class="bg-white p-6" style="margin: auto; max-width: 600px">
-    <div class="text-center mb-6">
-      <a href="https://simplelogin.io">
-        <img src="/static/logo.svg" style="background-color: transparent; height: 24px">
-      </a>
-    </div>
+  <div class="card mx-auto" style="max-width: 600px">
+    <div class="card-body">
+      <div class="text-center mb-6">
+        <a href="https://simplelogin.io">
+          <img src="/static/logo.svg" style="background-color: transparent; height: 24px">
+        </a>
+      </div>
 
-    <div>
-      <b>{{ client.name }}</b> would like to have access to your following data:
-    </div>
+      <div>
+        <b>{{ client.name }}</b> would like to have access to your following data:
+      </div>
 
-    <div>
-      <ul class="mt-3">
-        {% for scope in client.get_scopes() %}
-          <li>
-            {% if scope == Scope.AVATAR_URL %}
-              avatar
-            {% else %}
-              {{ scope.value }}
-            {% endif %}
-          </li>
-        {% endfor %}
-      </ul>
-    </div>
+      <div>
+        <ul class="mt-3">
+          {% for scope in client.get_scopes() %}
+            <li>
+              {% if scope == Scope.AVATAR_URL %}
+                avatar
+              {% else %}
+                {{ scope.value }}
+              {% endif %}
+            </li>
+          {% endfor %}
+        </ul>
+      </div>
 
-    <div>
-      In order to accept the request, you need to sign in.
-    </div>
+      <div>
+        In order to accept the request, you need to sign in.
+      </div>
 
-    <div class="mt-4">
-      <div class="btn-group w-100">
+      <div class="mt-4">
+        <div class="btn-group w-100">
 
-        <a href="{{ url_for('auth.login', next=next) }}" class="btn btn-success">
-          Login
-        </a>
+          <a href="{{ url_for('auth.login', next=next) }}" class="btn btn-success">
+            Login
+          </a>
 
-        <a href="{{ url_for('auth.register', next=next) }}" class="btn btn-info">
-          Sign Up
-        </a>
+          <a href="{{ url_for('auth.register', next=next) }}" class="btn btn-info">
+            Sign Up
+          </a>
+        </div>
       </div>
-    </div>
 
-    <hr>
+      <hr>
 
-    <div class="">
-      <p class="text-center col">Cancel and go back to <b>{{ client.name }}</b></p>
-      <a class="btn btn-block btn-secondary back-or-close">
-        <i class="fe fe-arrow-left mr-2"></i>Cancel
-      </a>
-    </div>
+      <div class="">
+        <p class="text-center col">Cancel and go back to <b>{{ client.name }}</b></p>
+        <a class="btn btn-block btn-secondary back-or-close">
+          <i class="fe fe-arrow-left mr-2"></i>Cancel
+        </a>
+      </div>
 
-    <div class="small-text mt-4">
-      <a href="https://simplelogin.io">SimpleLogin</a> is an open source social login provider that protects your privacy.
-    </div>
+      <div class="small-text mt-4">
+        <a href="https://simplelogin.io">SimpleLogin</a> is an open source social login provider that protects your
+        privacy.
+      </div>
 
+    </div>
   </div>
 
 {% endblock %}

+ 5 - 6
cron.py

@@ -163,17 +163,16 @@ def stats_before(moment: Arrow) -> Stats:
     LOG.d("total number alias %s", nb_alias)
 
     # email log stats
-    q = db.session.query(EmailLog, Contact, Alias, User).filter(
-        EmailLog.contact_id == Contact.id,
-        Contact.alias_id == Alias.id,
-        Alias.user_id == User.id,
-        EmailLog.created_at < moment,
+    q = (
+        db.session.query(EmailLog)
+        .join(User, EmailLog.user_id == User.id)
+        .filter(EmailLog.created_at < moment,)
     )
     for ie in IGNORED_EMAILS:
         q = q.filter(~User.email.contains(ie))
 
     nb_spam = nb_bounced = nb_forward = nb_block = nb_reply = 0
-    for email_log, _, _, _ in q:
+    for email_log in q:
         if email_log.bounced:
             nb_bounced += 1
         elif email_log.is_spam:

+ 27 - 18
email_handler.py

@@ -114,13 +114,30 @@ def new_app():
     return app
 
 
-def get_or_create_contact(contact_from_header: str, alias: Alias) -> Contact:
+def get_or_create_contact(
+    contact_from_header: str, mail_from: str, alias: Alias
+) -> Contact:
     """
     contact_from_header is the RFC 2047 format FROM header
     """
+    # contact_from_header can be None, use mail_from in this case instead
+    contact_from_header = contact_from_header or mail_from
+
     # force convert header to string, sometimes contact_from_header is Header object
     contact_from_header = str(contact_from_header)
+
     contact_name, contact_email = parseaddr_unicode(contact_from_header)
+    if not contact_email:
+        # From header is wrongly formatted, try with mail_from
+        LOG.warning("From header is empty, parse mail_from %s %s", mail_from, alias)
+        contact_name, contact_email = parseaddr_unicode(mail_from)
+        if not contact_email:
+            LOG.error(
+                "Cannot parse contact from from_header:%s, mail_from:%s",
+                contact_from_header,
+                mail_from,
+            )
+
     contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
     if contact:
         if contact.name != contact_name:
@@ -327,7 +344,7 @@ def handle_forward(
             LOG.d("alias %s cannot be created on-the-fly, return 550", address)
             return [(False, "550 SL E3")]
 
-    contact = get_or_create_contact(msg["From"], alias)
+    contact = get_or_create_contact(msg["From"], envelope.mail_from, alias)
     email_log = EmailLog.create(contact_id=contact.id, user_id=contact.user_id)
 
     if not alias.enabled:
@@ -363,25 +380,20 @@ def forward_email_to_mailbox(
 ) -> (bool, str):
     LOG.d("Forward %s -> %s -> %s", contact, alias, mailbox)
     spam_check = True
+    is_spam, spam_status = get_spam_info(msg)
+    if is_spam:
+        LOG.warning("Email detected as spam. Alias: %s, from: %s", alias, contact)
+        email_log.is_spam = True
+        email_log.spam_status = spam_status
+
+        handle_spam(contact, alias, msg, user, mailbox.email, email_log)
+        return False, "550 SL E1"
 
     # create PGP email if needed
     if mailbox.pgp_finger_print and user.is_premium():
         LOG.d("Encrypt message using mailbox %s", mailbox)
         msg = prepare_pgp_message(msg, mailbox.pgp_finger_print)
 
-        # no need to spam check for encrypted message
-        spam_check = False
-
-    if spam_check:
-        is_spam, spam_status = get_spam_info(msg)
-        if is_spam:
-            LOG.warning("Email detected as spam. Alias: %s, from: %s", alias, contact)
-            email_log.is_spam = True
-            email_log.spam_status = spam_status
-
-            handle_spam(contact, alias, msg, user, mailbox.email, email_log)
-            return False, "550 SL E1"
-
     # add custom header
     add_or_replace_header(msg, "X-SimpleLogin-Type", "Forward")
 
@@ -616,9 +628,6 @@ def spf_pass(
                         subject=msg["Subject"],
                         time=arrow.now(),
                     ),
-                    # as the returned error status is 4**,
-                    # the sender will try to resend the email. Send the error message only once
-                    max_alert_24h=1,
                 )
                 return False
 

+ 3 - 2
server.py

@@ -91,13 +91,13 @@ def create_app() -> Flask:
         app.config["SESSION_COOKIE_SECURE"] = True
     app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
 
+    setup_error_page(app)
+
     init_extensions(app)
     register_blueprints(app)
     set_index_page(app)
     jinja2_filter(app)
 
-    setup_error_page(app)
-
     setup_favicon_route(app)
     setup_openid_metadata(app)
 
@@ -140,6 +140,7 @@ def fake_data():
         activated=True,
         is_admin=True,
         otp_secret="base32secret3232",
+        can_use_fido=True,
     )
     db.session.commit()
     user.trial_end = None

+ 2 - 2
static/assets/css/darkmode.css

@@ -9,7 +9,7 @@
     --heading-color: #818cab;
     --heading-background: #FFF;
     --border: 1px solid rgba(0, 40, 100, 0.12);
-    --input-bg-color: var(--light);
+    --input-bg-color: var(--white);
 }
 
 [data-theme="dark"] {
@@ -46,7 +46,7 @@ hr {
     background-color: var(--input-bg-color);
 }
 
-.form-control:focus, .dataTables_wrapper .dataTables_length select:focus, .dataTables_wrapper .dataTables_filter input:focus {
+.form-control:focus, .dataTables_wrapper .dataTables_length select:focus, .dataTables_wrapper .dataTables_filter input:focus, .modal-content {
     border-color: #1991eb;
     outline: 0;
     box-shadow: 0 0 0 2px rgba(70, 127, 207, 0.25);

+ 1 - 14
static/assets/js/core.js

@@ -104,17 +104,4 @@ $(document).ready(function() {
       });
     });
   }
-
-  /** Dark mode controller */
-  if (store.get('dark-mode') === true) {
-    document.documentElement.setAttribute('data-theme', 'dark')
-  }
-  $('[data-toggle="dark-mode"]').on('click', function () {
-    if (store.get('dark-mode') === true) {
-      store.set('dark-mode', false);
-      return document.documentElement.setAttribute('data-theme', 'light')
-    }
-    store.set('dark-mode', true)
-    document.documentElement.setAttribute('data-theme', 'dark')
-  })
-});
+});

+ 40 - 0
static/js/theme.js

@@ -0,0 +1,40 @@
+let setCookie = function(name, value, days) {
+    if (!name || !value) return false;
+    let expires = '';
+    let secure = '';
+    if (location.protocol === 'https:') secure = 'Secure; ';
+
+    if (days) {
+      let date = new Date();
+      date.setTime(date.getTime() + (days * 24*60*60*1000));
+      expires = 'Expires=' + date.toUTCString() + '; ';
+    }
+
+    document.cookie = name + '=' + value + '; ' +
+                      expires +
+                      secure +
+                      'sameSite=Lax; ' +
+                      'domain=' + window.location.hostname + '; ' +
+                      'path=/';
+    return true;
+  }
+
+let getCookie = function(name) {
+  let match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
+  if (match) return match[2];
+}
+
+$(document).ready(function() {
+  /** Dark mode controller */
+  if (getCookie('dark-mode') === "true") {
+    document.documentElement.setAttribute('data-theme', 'dark');
+  }
+  $('[data-toggle="dark-mode"]').on('click', function () {
+    if (getCookie('dark-mode') === "true") {
+      setCookie('dark-mode', 'false', 30);
+      return document.documentElement.setAttribute('data-theme', 'light')
+    }
+    setCookie('dark-mode', 'true', 30);
+    document.documentElement.setAttribute('data-theme', 'dark')
+  })
+});

+ 8 - 4
static/style.css

@@ -72,14 +72,18 @@ em {
 }
 
 /*Left border for alert zone*/
-.alert-primary{
+.alert-primary {
     border-left: 5px #467fcf solid;
 }
 
-.alert-danger{
+.alert-danger {
     border-left: 5px #6b1110 solid;
 }
 
 .alert-danger::before {
-  content: "⚠️";
-}
+    content: "⚠️";
+}
+
+.dns-record {
+    border: 1px dotted #E3156A;
+}

+ 5 - 2
templates/base.html

@@ -1,7 +1,7 @@
 {% from "_formhelpers.html" import render_field, render_field_errors %}
 
 <!doctype html>
-<html lang="en" dir="ltr">
+<html lang="en" dir="ltr" data-theme="{% if request.cookies.get('dark-mode') == 'true' %}dark{% endif %}">
 <head>
   <meta charset="UTF-8">
   <meta name="viewport"
@@ -69,6 +69,8 @@
 
   <link rel="stylesheet" type="text/css" href="/static/style.css?v={{ VERSION }}">
 
+  <script src="{{ url_for('static', filename='js/theme.js') }}"></script>
+
   <script>
     toastr.options.closeButton = true;
   </script>
@@ -175,7 +177,8 @@
 
 </script>
 
-<script src="/static/local-storage-polyfill.js"></script>
+<script src="{{ url_for('static', filename='local-storage-polyfill.js') }}"></script>
+
 
 <!-- For additional script -->
 {% block script %}

+ 1 - 1
templates/emails/base.html

@@ -449,7 +449,7 @@
         <tr>
           <td class="email-masthead" style="word-break: break-word; font-family: Helvetica, Arial, sans-serif; font-size: 16px; text-align: center; padding: 25px 0;" align="center">
             <a href="https://simplelogin.io" class="f-fallback email-masthead_name" style="color: #A8AAAF; font-size: 16px; font-weight: bold; text-decoration: none; text-shadow: 0 1px 0 white;">
-              SimpleLogin
+              <img src="https://simplelogin.io/logo.png" style="width: 150px; margin: auto">
             </a>
           </td>
         </tr>

+ 1 - 1
templates/emails/com/newsletter/mailbox.html

@@ -30,6 +30,6 @@ maybe for different uses: a Gmail account for social networks & forums, a Pronto
 {% endblock %}
 
 {% block footer %}
-  This email is sent to {{ user.email }} and is part of our onboarding series. Unsubscribe on
+  This email is sent to {{ user.email }}. Unsubscribe on
   <a href="https://app.simplelogin.io/dashboard/setting#notification">Settings</a>
 {% endblock %}

+ 1 - 1
templates/emails/com/newsletter/mailbox.txt

@@ -1,4 +1,4 @@
-This email is sent to {{ user.email }} and is part of our onboarding series.
+This email is sent to {{ user.email }}.
 Unsubscribe from our emails on https://app.simplelogin.io/dashboard/setting#notification
 ----------------
 

+ 14 - 8
templates/emails/com/newsletter/mobile-darkmode.html

@@ -4,7 +4,8 @@
   {{ render_text("Hi " + user.name) }}
 
   {% call text() %}
-    Son from SimpleLogin here. I hope you are doing well and are staying at home in this difficult time. By the way I'm writing this newsletter from my couch with my cats proofreading the text :). <br>
+    Son from SimpleLogin here. I hope you are doing well and are staying at home in this difficult time. By the way I'm
+    writing this newsletter from my couch with my cats proofreading the text :). <br>
     Please find below some of our latest news. <br>
 
   {% endcall %}
@@ -49,8 +50,10 @@
 
     You can set a name for your alias too: this name is used when you send emails or reply from your alias.<br>
 
-    We have also created a new <a href="https://simplelogin.io/security/">security page</a> that goes into the technical details of SimpleLogin.
-    Our <a href="https://simplelogin.io/privacy/">privacy page</a> is also rewritten from scratch: nothing changes about your data protection
+    We have also created a new <a href="https://simplelogin.io/security/">security page</a> that goes into the technical
+    details of SimpleLogin.
+    Our <a href="https://simplelogin.io/privacy/">privacy page</a> is also rewritten from scratch: nothing changes about
+    your data protection
     but the page is more clear and detailed now.
 
   {% endcall %}
@@ -83,16 +86,19 @@
   {% endcall %}
 
   {% call text() %}
-    We want to say thank you to all users who have helped to improve SimpleLogin code and even
-    contribute important features.
+    <hr style="margin: 10px;">
+    On behalf of the team, I want to say thank you to all users who have helped to improve SimpleLogin code
+    and even contribute important features.
     That means a lot to us as SimpleLogin is after all an open-source project.
 
   {% endcall %}
 
   {% call text() %}
-    We always welcome your feedback. Get in touch on our <a href="https://twitter.com/simple_login">Twitter</a>,
-    <a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>, where you can also follow all our latest updates.
-    <br>
+    That's all for today. If you want to follow all our latest features, you can follow our
+    <a href="https://twitter.com/simple_login">Twitter</a> or join our
+    <a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>
+    or subscribe to our <a href="https://feed43.com/simplelogin.xml">RSS feed</a>. <br>
+    Now back to coding :).
   {% endcall %}
 
   {% call text() %}

+ 1 - 2
templates/footer.html

@@ -33,8 +33,7 @@
       </div>
       <div class="col-12 col-lg-auto mt-3 mt-lg-0 text-center">
         Copyright © {{ YEAR }}
-        <a href="https://simplelogin.io" target="_blank">SimpleLogin
-        </a>.
+        <a href="https://simplelogin.io" target="_blank">SimpleLogin</a>.
         All rights reserved.
       </div>
     </div>

+ 11 - 11
tests/test_email_utils.py

@@ -4,7 +4,7 @@ from app.config import MAX_ALERT_24H
 from app.email_utils import (
     get_email_domain_part,
     email_belongs_to_alias_domains,
-    can_be_used_as_personal_email,
+    email_domain_can_be_used_as_mailbox,
     delete_header,
     add_or_replace_header,
     parseaddr_unicode,
@@ -29,10 +29,10 @@ def test_email_belongs_to_alias_domains():
 
 def test_can_be_used_as_personal_email(flask_client):
     # default alias domain
-    assert not can_be_used_as_personal_email("ab@sl.local")
-    assert not can_be_used_as_personal_email("hey@d1.test")
+    assert not email_domain_can_be_used_as_mailbox("ab@sl.local")
+    assert not email_domain_can_be_used_as_mailbox("hey@d1.test")
 
-    assert can_be_used_as_personal_email("hey@ab.cd")
+    assert email_domain_can_be_used_as_mailbox("hey@ab.cd")
     # custom domain
     user = User.create(
         email="a@b.c", password="password", name="Test User", activated=True
@@ -40,17 +40,17 @@ def test_can_be_used_as_personal_email(flask_client):
     db.session.commit()
     CustomDomain.create(user_id=user.id, domain="ab.cd", verified=True)
     db.session.commit()
-    assert not can_be_used_as_personal_email("hey@ab.cd")
+    assert not email_domain_can_be_used_as_mailbox("hey@ab.cd")
 
     # disposable domain
-    assert not can_be_used_as_personal_email("abcd@10minutesmail.fr")
-    assert not can_be_used_as_personal_email("abcd@temp-mail.com")
+    assert not email_domain_can_be_used_as_mailbox("abcd@10minutesmail.fr")
+    assert not email_domain_can_be_used_as_mailbox("abcd@temp-mail.com")
     # subdomain will not work
-    assert not can_be_used_as_personal_email("abcd@sub.temp-mail.com")
+    assert not email_domain_can_be_used_as_mailbox("abcd@sub.temp-mail.com")
     # valid domains should not be affected
-    assert can_be_used_as_personal_email("abcd@protonmail.com")
-    assert can_be_used_as_personal_email("abcd@gmail.com")
-    assert can_be_used_as_personal_email("abcd@example.com")
+    assert email_domain_can_be_used_as_mailbox("abcd@protonmail.com")
+    assert email_domain_can_be_used_as_mailbox("abcd@gmail.com")
+    assert email_domain_can_be_used_as_mailbox("abcd@example.com")
 
 
 def test_delete_header():