Merge branch 'master' into fix-doc
This commit is contained in:
commit
c78e3a6ee2
|
@ -1,2 +1,3 @@
|
|||
patreon: simplelogin
|
||||
open_collective: simple_login
|
||||
custom: ["https://www.paypal.me/RealSimpleLogin"]
|
||||
|
|
|
@ -11,4 +11,4 @@ db.sqlite-journal
|
|||
static/upload
|
||||
adhoc_*
|
||||
adhoc.py
|
||||
env/
|
||||
venv/
|
45
README.md
45
README.md
|
@ -490,6 +490,19 @@ sudo docker run --rm \
|
|||
|
||||
This command could take a while to download the `simplelogin/app` docker image.
|
||||
|
||||
Init data
|
||||
|
||||
```bash
|
||||
sudo docker run --rm \
|
||||
--name sl-init \
|
||||
-v $(pwd)/sl:/sl \
|
||||
-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:3.2.2 python init_app.py
|
||||
```
|
||||
|
||||
Now, it's time to run the `webapp` container!
|
||||
|
||||
```bash
|
||||
|
@ -797,6 +810,38 @@ Output: if api key is correct, return a json with user name and whether user is
|
|||
|
||||
If api key is incorrect, return 401.
|
||||
|
||||
#### POST /api/api_key
|
||||
|
||||
Create a new API Key
|
||||
|
||||
Input:
|
||||
- `Authentication` header that contains the api key
|
||||
- Or the correct cookie is set, i.e. user is already logged in on the web
|
||||
- device: device's name
|
||||
|
||||
Output
|
||||
- 401 if user is not authenticated
|
||||
- 201 with the `api_key`
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "long string"
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /api/logout
|
||||
|
||||
Log user out
|
||||
|
||||
Input:
|
||||
- `Authentication` header that contains the api key
|
||||
- Or the correct cookie is set, i.e. user is already logged in on the web
|
||||
|
||||
Output:
|
||||
- 401 if user is not authenticated
|
||||
- 200 if success
|
||||
|
||||
|
||||
### Alias endpoints
|
||||
|
||||
#### GET /api/v4/alias/options
|
||||
|
|
|
@ -147,7 +147,7 @@ def delete_alias(alias: Alias, user: User):
|
|||
)
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
LOG.error(
|
||||
LOG.exception(
|
||||
"alias %s domain %s has been added before to DeletedAlias",
|
||||
alias.email,
|
||||
alias.custom_domain_id,
|
||||
|
@ -158,5 +158,5 @@ def delete_alias(alias: Alias, user: User):
|
|||
DeletedAlias.create(email=alias.email)
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
LOG.error("alias %s has been added before to DeletedAlias", alias.email)
|
||||
LOG.exception("alias %s has been added before to DeletedAlias", alias.email)
|
||||
db.session.rollback()
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
from flask import g
|
||||
from flask import jsonify
|
||||
from flask import request
|
||||
from flask_cors import cross_origin
|
||||
|
||||
from app import alias_utils
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
|
@ -25,7 +24,6 @@ from app.utils import random_string
|
|||
|
||||
|
||||
@api_bp.route("/aliases", methods=["GET", "POST"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def get_aliases():
|
||||
"""
|
||||
|
@ -68,7 +66,6 @@ def get_aliases():
|
|||
|
||||
|
||||
@api_bp.route("/v2/aliases", methods=["GET", "POST"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def get_aliases_v2():
|
||||
"""
|
||||
|
@ -121,7 +118,6 @@ def get_aliases_v2():
|
|||
|
||||
|
||||
@api_bp.route("/aliases/<int:alias_id>", methods=["DELETE"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def delete_alias(alias_id):
|
||||
"""
|
||||
|
@ -135,7 +131,7 @@ def delete_alias(alias_id):
|
|||
user = g.user
|
||||
alias = Alias.get(alias_id)
|
||||
|
||||
if alias.user_id != user.id:
|
||||
if not alias or alias.user_id != user.id:
|
||||
return jsonify(error="Forbidden"), 403
|
||||
|
||||
alias_utils.delete_alias(alias, user)
|
||||
|
@ -144,7 +140,6 @@ def delete_alias(alias_id):
|
|||
|
||||
|
||||
@api_bp.route("/aliases/<int:alias_id>/toggle", methods=["POST"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def toggle_alias(alias_id):
|
||||
"""
|
||||
|
@ -170,7 +165,6 @@ def toggle_alias(alias_id):
|
|||
|
||||
|
||||
@api_bp.route("/aliases/<int:alias_id>/activities")
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def get_alias_activities(alias_id):
|
||||
"""
|
||||
|
@ -226,7 +220,6 @@ def get_alias_activities(alias_id):
|
|||
|
||||
|
||||
@api_bp.route("/aliases/<int:alias_id>", methods=["PUT"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def update_alias(alias_id):
|
||||
"""
|
||||
|
@ -247,7 +240,7 @@ def update_alias(alias_id):
|
|||
user = g.user
|
||||
alias: Alias = Alias.get(alias_id)
|
||||
|
||||
if alias.user_id != user.id:
|
||||
if not alias or alias.user_id != user.id:
|
||||
return jsonify(error="Forbidden"), 403
|
||||
|
||||
changed = False
|
||||
|
@ -310,7 +303,6 @@ def update_alias(alias_id):
|
|||
|
||||
|
||||
@api_bp.route("/aliases/<int:alias_id>", methods=["GET"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def get_alias(alias_id):
|
||||
"""
|
||||
|
@ -324,6 +316,9 @@ def get_alias(alias_id):
|
|||
user = g.user
|
||||
alias: Alias = Alias.get(alias_id)
|
||||
|
||||
if not alias:
|
||||
return jsonify(error="Unknown error"), 400
|
||||
|
||||
if alias.user_id != user.id:
|
||||
return jsonify(error="Forbidden"), 403
|
||||
|
||||
|
@ -331,7 +326,6 @@ def get_alias(alias_id):
|
|||
|
||||
|
||||
@api_bp.route("/aliases/<int:alias_id>/contacts")
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def get_alias_contacts_route(alias_id):
|
||||
"""
|
||||
|
@ -365,7 +359,6 @@ def get_alias_contacts_route(alias_id):
|
|||
|
||||
|
||||
@api_bp.route("/aliases/<int:alias_id>/contacts", methods=["POST"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def create_contact_route(alias_id):
|
||||
"""
|
||||
|
@ -420,7 +413,6 @@ def create_contact_route(alias_id):
|
|||
|
||||
|
||||
@api_bp.route("/contacts/<int:contact_id>", methods=["DELETE"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def delete_contact(contact_id):
|
||||
"""
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from flask import jsonify, request, g
|
||||
from flask_cors import cross_origin
|
||||
from sqlalchemy import desc
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
|
@ -12,7 +11,6 @@ from app.utils import convert_to_id, random_word
|
|||
|
||||
|
||||
@api_bp.route("/alias/options")
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def options():
|
||||
"""
|
||||
|
@ -88,7 +86,6 @@ def options():
|
|||
|
||||
|
||||
@api_bp.route("/v2/alias/options")
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def options_v2():
|
||||
"""
|
||||
|
@ -169,7 +166,6 @@ def options_v2():
|
|||
|
||||
|
||||
@api_bp.route("/v3/alias/options")
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def options_v3():
|
||||
"""
|
||||
|
@ -246,7 +242,6 @@ def options_v3():
|
|||
|
||||
|
||||
@api_bp.route("/v4/alias/options")
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def options_v4():
|
||||
"""
|
||||
|
|
|
@ -5,7 +5,6 @@ import requests
|
|||
from flask import g
|
||||
from flask import jsonify
|
||||
from flask import request
|
||||
from flask_cors import cross_origin
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.config import APPLE_API_SECRET, MACAPP_APPLE_API_SECRET
|
||||
|
@ -25,7 +24,6 @@ _PROD_URL = "https://buy.itunes.apple.com/verifyReceipt"
|
|||
|
||||
|
||||
@api_bp.route("/apple/process_payment", methods=["POST"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def apple_process_payment():
|
||||
"""
|
||||
|
@ -304,6 +302,9 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
|||
r = requests.post(
|
||||
_PROD_URL, json={"receipt-data": receipt_data, "password": password}
|
||||
)
|
||||
if r.status_code >= 500:
|
||||
LOG.warning("Apple server error, response:%s %s", r, r.content)
|
||||
return None
|
||||
|
||||
if r.json() == {"status": 21007}:
|
||||
# try sandbox_url
|
||||
|
@ -518,7 +519,7 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
|||
else:
|
||||
# the same original_transaction_id has been used on another account
|
||||
if AppleSubscription.get_by(original_transaction_id=original_transaction_id):
|
||||
LOG.error("Same Apple Sub has been used before, current user %s", user)
|
||||
LOG.exception("Same Apple Sub has been used before, current user %s", user)
|
||||
return None
|
||||
|
||||
LOG.d(
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import random
|
||||
|
||||
import facebook
|
||||
import google.oauth2.credentials
|
||||
import googleapiclient.discovery
|
||||
import random
|
||||
from flask import jsonify, request, g
|
||||
from flask_cors import cross_origin
|
||||
from flask_login import login_user
|
||||
from itsdangerous import Signer
|
||||
|
||||
from app import email_utils
|
||||
|
@ -22,7 +23,6 @@ from app.models import User, ApiKey, SocialAuth, AccountActivation
|
|||
|
||||
|
||||
@api_bp.route("/auth/login", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit(
|
||||
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
|
||||
)
|
||||
|
@ -68,7 +68,6 @@ def auth_login():
|
|||
|
||||
|
||||
@api_bp.route("/auth/register", methods=["POST"])
|
||||
@cross_origin()
|
||||
def auth_register():
|
||||
"""
|
||||
User signs up - will need to activate their account with an activation code.
|
||||
|
@ -116,7 +115,6 @@ def auth_register():
|
|||
|
||||
|
||||
@api_bp.route("/auth/activate", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit(
|
||||
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
|
||||
)
|
||||
|
@ -176,7 +174,6 @@ def auth_activate():
|
|||
|
||||
|
||||
@api_bp.route("/auth/reactivate", methods=["POST"])
|
||||
@cross_origin()
|
||||
def auth_reactivate():
|
||||
"""
|
||||
User asks for another activation code
|
||||
|
@ -218,7 +215,6 @@ def auth_reactivate():
|
|||
|
||||
|
||||
@api_bp.route("/auth/facebook", methods=["POST"])
|
||||
@cross_origin()
|
||||
def auth_facebook():
|
||||
"""
|
||||
Authenticate user with Facebook
|
||||
|
@ -269,7 +265,6 @@ def auth_facebook():
|
|||
|
||||
|
||||
@api_bp.route("/auth/google", methods=["POST"])
|
||||
@cross_origin()
|
||||
def auth_google():
|
||||
"""
|
||||
Authenticate user with Facebook
|
||||
|
@ -339,11 +334,13 @@ def auth_payload(user, device) -> dict:
|
|||
ret["mfa_key"] = None
|
||||
ret["api_key"] = api_key.code
|
||||
|
||||
# so user is automatically logged in on the web
|
||||
login_user(user)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
@api_bp.route("/auth/forgot_password", methods=["POST"])
|
||||
@cross_origin()
|
||||
def forgot_password():
|
||||
"""
|
||||
User forgot password
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import pyotp
|
||||
from flask import jsonify, request
|
||||
from flask_cors import cross_origin
|
||||
from flask_login import login_user
|
||||
from itsdangerous import Signer
|
||||
|
||||
from app.api.base import api_bp
|
||||
|
@ -11,7 +11,6 @@ from app.models import User, ApiKey
|
|||
|
||||
|
||||
@api_bp.route("/auth/mfa", methods=["POST"])
|
||||
@cross_origin()
|
||||
def auth_mfa():
|
||||
"""
|
||||
Validate the OTP Token
|
||||
|
@ -66,4 +65,7 @@ def auth_mfa():
|
|||
|
||||
ret["api_key"] = api_key.code
|
||||
|
||||
# so user is logged in automatically on the web
|
||||
login_user(user)
|
||||
|
||||
return jsonify(**ret), 200
|
||||
|
|
|
@ -3,7 +3,6 @@ from smtplib import SMTPRecipientsRefused
|
|||
from flask import g
|
||||
from flask import jsonify
|
||||
from flask import request
|
||||
from flask_cors import cross_origin
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.dashboard.views.mailbox import send_verification_email
|
||||
|
@ -17,7 +16,6 @@ from app.models import Mailbox
|
|||
|
||||
|
||||
@api_bp.route("/mailboxes", methods=["POST"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def create_mailbox():
|
||||
"""
|
||||
|
@ -62,7 +60,6 @@ def create_mailbox():
|
|||
|
||||
|
||||
@api_bp.route("/mailboxes/<mailbox_id>", methods=["DELETE"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def delete_mailbox(mailbox_id):
|
||||
"""
|
||||
|
@ -89,7 +86,6 @@ def delete_mailbox(mailbox_id):
|
|||
|
||||
|
||||
@api_bp.route("/mailboxes/<mailbox_id>", methods=["PUT"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def update_mailbox(mailbox_id):
|
||||
"""
|
||||
|
@ -152,7 +148,6 @@ def update_mailbox(mailbox_id):
|
|||
|
||||
|
||||
@api_bp.route("/mailboxes", methods=["GET"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def get_mailboxes():
|
||||
"""
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
from flask import g
|
||||
from flask import jsonify, request
|
||||
from flask_cors import cross_origin
|
||||
from itsdangerous import SignatureExpired
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
|
@ -12,7 +11,7 @@ from app.api.serializer import (
|
|||
)
|
||||
from app.config import MAX_NB_EMAIL_FREE_PLAN
|
||||
from app.dashboard.views.custom_alias import verify_prefix_suffix, signer
|
||||
from app.extensions import db
|
||||
from app.extensions import db, limiter
|
||||
from app.log import LOG
|
||||
from app.models import (
|
||||
Alias,
|
||||
|
@ -28,7 +27,7 @@ from app.utils import convert_to_id
|
|||
|
||||
|
||||
@api_bp.route("/alias/custom/new", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit("5/minute")
|
||||
@require_api_auth
|
||||
def new_custom_alias():
|
||||
"""
|
||||
|
@ -99,7 +98,7 @@ def new_custom_alias():
|
|||
|
||||
|
||||
@api_bp.route("/v2/alias/custom/new", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit("5/minute")
|
||||
@require_api_auth
|
||||
def new_custom_alias_v2():
|
||||
"""
|
||||
|
@ -142,10 +141,10 @@ def new_custom_alias_v2():
|
|||
try:
|
||||
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
||||
except SignatureExpired:
|
||||
LOG.error("Alias creation time expired for %s", user)
|
||||
return jsonify(error="alias creation is expired, please try again"), 400
|
||||
LOG.warning("Alias creation time expired for %s", user)
|
||||
return jsonify(error="Alias creation time is expired, please retry"), 412
|
||||
except Exception:
|
||||
LOG.error("Alias suffix is tampered, user %s", user)
|
||||
LOG.exception("Alias suffix is tampered, user %s", user)
|
||||
return jsonify(error="Tampered suffix"), 400
|
||||
|
||||
if not verify_prefix_suffix(user, alias_prefix, alias_suffix):
|
||||
|
@ -194,7 +193,7 @@ def new_custom_alias_v2():
|
|||
|
||||
|
||||
@api_bp.route("/v3/alias/custom/new", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit("5/minute")
|
||||
@require_api_auth
|
||||
def new_custom_alias_v3():
|
||||
"""
|
||||
|
@ -252,10 +251,10 @@ def new_custom_alias_v3():
|
|||
try:
|
||||
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
||||
except SignatureExpired:
|
||||
LOG.error("Alias creation time expired for %s", user)
|
||||
return jsonify(error="alias creation is expired, please try again"), 400
|
||||
LOG.warning("Alias creation time expired for %s", user)
|
||||
return jsonify(error="Alias creation time is expired, please retry"), 412
|
||||
except Exception:
|
||||
LOG.error("Alias suffix is tampered, user %s", user)
|
||||
LOG.exception("Alias suffix is tampered, user %s", user)
|
||||
return jsonify(error="Tampered suffix"), 400
|
||||
|
||||
if not verify_prefix_suffix(user, alias_prefix, alias_suffix):
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
from flask import g
|
||||
from flask import jsonify, request
|
||||
from flask_cors import cross_origin
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.api.serializer import (
|
||||
|
@ -8,13 +7,13 @@ from app.api.serializer import (
|
|||
serialize_alias_info_v2,
|
||||
)
|
||||
from app.config import MAX_NB_EMAIL_FREE_PLAN
|
||||
from app.extensions import db
|
||||
from app.extensions import db, limiter
|
||||
from app.log import LOG
|
||||
from app.models import Alias, AliasUsedOn, AliasGeneratorEnum
|
||||
|
||||
|
||||
@api_bp.route("/alias/random/new", methods=["POST"])
|
||||
@cross_origin()
|
||||
@limiter.limit("5/minute")
|
||||
@require_api_auth
|
||||
def new_random_alias():
|
||||
"""
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
from flask import g
|
||||
from flask import jsonify
|
||||
from flask import request
|
||||
from flask_cors import cross_origin
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.config import PAGE_LIMIT
|
||||
|
@ -10,7 +9,6 @@ from app.models import Notification
|
|||
|
||||
|
||||
@api_bp.route("/notifications", methods=["GET"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def get_notifications():
|
||||
"""
|
||||
|
@ -61,7 +59,6 @@ def get_notifications():
|
|||
|
||||
|
||||
@api_bp.route("/notifications/<notification_id>/read", methods=["POST"])
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def mark_as_read(notification_id):
|
||||
"""
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
from flask import jsonify, g
|
||||
from flask_cors import cross_origin
|
||||
from flask import jsonify, g, request, make_response
|
||||
from flask_login import logout_user
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.config import SESSION_COOKIE_NAME
|
||||
from app.extensions import db
|
||||
from app.models import ApiKey
|
||||
|
||||
|
||||
@api_bp.route("/user_info")
|
||||
@cross_origin()
|
||||
@require_api_auth
|
||||
def user_info():
|
||||
"""
|
||||
|
@ -21,3 +23,41 @@ def user_info():
|
|||
"in_trial": user.in_trial(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/api_key", methods=["POST"])
|
||||
@require_api_auth
|
||||
def create_api_key():
|
||||
"""Used to create a new api key
|
||||
Input:
|
||||
- device
|
||||
|
||||
Output:
|
||||
- api_key
|
||||
"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify(error="request body cannot be empty"), 400
|
||||
|
||||
device = data.get("device")
|
||||
|
||||
api_key = ApiKey.create(user_id=g.user.id, name=device)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(api_key=api_key.code), 201
|
||||
|
||||
|
||||
@api_bp.route("/logout", methods=["GET"])
|
||||
@require_api_auth
|
||||
def logout():
|
||||
"""
|
||||
Log user out on the web, i.e. remove the cookie
|
||||
|
||||
Output:
|
||||
- 200
|
||||
"""
|
||||
logout_user()
|
||||
response = make_response(jsonify(msg="User is logged out"), 200)
|
||||
response.delete_cookie(SESSION_COOKIE_NAME)
|
||||
|
||||
return response
|
||||
|
|
|
@ -31,6 +31,11 @@
|
|||
</div>
|
||||
-->
|
||||
|
||||
{% if HCAPTCHA_SITEKEY %}
|
||||
<div class="h-captcha" data-sitekey="{{ HCAPTCHA_SITEKEY }}"></div>
|
||||
<script src="https://hcaptcha.com/1/api.js" async defer></script>
|
||||
{% endif %}
|
||||
|
||||
<small class="text-center mt-3">
|
||||
By clicking Create Account, you agree to abide by
|
||||
<a href="https://simplelogin.io/terms">SimpleLogin's Terms and Conditions.</a>
|
||||
|
|
|
@ -94,7 +94,7 @@ def fido():
|
|||
)
|
||||
new_sign_count = webauthn_assertion_response.verify()
|
||||
except Exception as e:
|
||||
LOG.error(f"An error occurred in WebAuthn verification process: {e}")
|
||||
LOG.exception(f"An error occurred in WebAuthn verification process: {e}")
|
||||
flash("Key verification failed.", "warning")
|
||||
# Trigger rate limiter
|
||||
g.deduct_limit = True
|
||||
|
|
|
@ -75,7 +75,7 @@ def github_callback():
|
|||
break
|
||||
|
||||
if not email:
|
||||
LOG.error(f"cannot get email for github user {github_user_data} {emails}")
|
||||
LOG.exception(f"cannot get email for github user {github_user_data} {emails}")
|
||||
flash(
|
||||
"Cannot get a valid email from Github, please another way to login/sign up",
|
||||
"error",
|
||||
|
|
|
@ -2,6 +2,7 @@ from flask import redirect, url_for, flash, make_response
|
|||
from flask_login import logout_user
|
||||
|
||||
from app.auth.base import auth_bp
|
||||
from app.config import SESSION_COOKIE_NAME
|
||||
|
||||
|
||||
@auth_bp.route("/logout")
|
||||
|
@ -9,7 +10,7 @@ def logout():
|
|||
logout_user()
|
||||
flash("You are logged out", "success")
|
||||
response = make_response(redirect(url_for("auth.login")))
|
||||
response.delete_cookie("slapp")
|
||||
response.delete_cookie(SESSION_COOKIE_NAME)
|
||||
response.delete_cookie("mfa")
|
||||
response.delete_cookie("dark-mode")
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import requests
|
||||
from flask import request, flash, render_template, redirect, url_for
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
|
@ -6,7 +7,7 @@ from wtforms import StringField, validators
|
|||
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.config import URL, HCAPTCHA_SECRET, HCAPTCHA_SITEKEY
|
||||
from app.email_utils import (
|
||||
email_domain_can_be_used_as_mailbox,
|
||||
personal_email_already_used,
|
||||
|
@ -39,9 +40,34 @@ def register():
|
|||
next_url = request.args.get("next")
|
||||
|
||||
if form.validate_on_submit():
|
||||
# only check if hcaptcha is enabled
|
||||
if HCAPTCHA_SECRET:
|
||||
# check with hCaptcha
|
||||
token = request.form.get("h-captcha-response")
|
||||
params = {"secret": HCAPTCHA_SECRET, "response": token}
|
||||
hcaptcha_res = requests.post(
|
||||
"https://hcaptcha.com/siteverify", data=params
|
||||
).json()
|
||||
# return something like
|
||||
# {'success': True,
|
||||
# 'challenge_ts': '2020-07-23T10:03:25',
|
||||
# 'hostname': '127.0.0.1'}
|
||||
if not hcaptcha_res["success"]:
|
||||
LOG.warning(
|
||||
"User put wrong captcha %s %s", form.email.data, hcaptcha_res,
|
||||
)
|
||||
flash("Wrong Captcha", "error")
|
||||
return render_template(
|
||||
"auth/register.html",
|
||||
form=form,
|
||||
next_url=next_url,
|
||||
HCAPTCHA_SITEKEY=HCAPTCHA_SITEKEY,
|
||||
)
|
||||
|
||||
email = form.email.data.strip().lower()
|
||||
if not email_domain_can_be_used_as_mailbox(email):
|
||||
flash("You cannot use this email address as your personal inbox.", "error")
|
||||
|
||||
else:
|
||||
if personal_email_already_used(email):
|
||||
flash(f"Email {email} already used", "error")
|
||||
|
@ -63,7 +89,12 @@ def register():
|
|||
|
||||
return render_template("auth/register_waiting_activation.html")
|
||||
|
||||
return render_template("auth/register.html", form=form, next_url=next_url)
|
||||
return render_template(
|
||||
"auth/register.html",
|
||||
form=form,
|
||||
next_url=next_url,
|
||||
HCAPTCHA_SITEKEY=HCAPTCHA_SITEKEY,
|
||||
)
|
||||
|
||||
|
||||
def send_activation_email(user, next_url):
|
||||
|
|
|
@ -144,6 +144,7 @@ DB_URI = os.environ["DB_URI"]
|
|||
|
||||
# Flask secret
|
||||
FLASK_SECRET = os.environ["FLASK_SECRET"]
|
||||
SESSION_COOKIE_NAME = "slapp"
|
||||
MAILBOX_SECRET = FLASK_SECRET + "mailbox"
|
||||
CUSTOM_ALIAS_SECRET = FLASK_SECRET + "custom_alias"
|
||||
|
||||
|
@ -291,3 +292,6 @@ ALERT_SPF = "spf"
|
|||
|
||||
# Disable onboarding emails
|
||||
DISABLE_ONBOARDING = "DISABLE_ONBOARDING" in os.environ
|
||||
|
||||
HCAPTCHA_SECRET = os.environ.get("HCAPTCHA_SECRET")
|
||||
HCAPTCHA_SITEKEY = os.environ.get("HCAPTCHA_SITEKEY")
|
||||
|
|
|
@ -23,4 +23,5 @@ from .views import (
|
|||
referral,
|
||||
recovery_code,
|
||||
contact_detail,
|
||||
setup_done,
|
||||
)
|
||||
|
|
|
@ -36,12 +36,17 @@
|
|||
</form>
|
||||
|
||||
|
||||
|
||||
{% if referral.nb_user() > 0 %}
|
||||
{% set nb_user = referral.nb_user() %}
|
||||
{% set nb_paid_user = referral.nb_paid_user() %}
|
||||
{% if nb_user > 0 %}
|
||||
<div class="mb-3">
|
||||
<b class="h1">{{ referral.nb_user() }}</b>
|
||||
{% if referral.nb_user() == 1 %} person {% else %} people {% endif %}
|
||||
has protected their emails thanks to you!
|
||||
<b class="h1">{{ nb_user }}</b>
|
||||
{% if nb_user == 1 %} person {% else %} people {% endif %}
|
||||
has protected their emails thanks to you! <br>
|
||||
|
||||
Among them, <b class="h1">{{ nb_paid_user }}</b>
|
||||
{% if nb_paid_user == 1 %} person {% else %} people {% endif %}
|
||||
has upgraded their accounts.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -132,11 +132,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div id="random-alias" class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Random Alias</div>
|
||||
<div class="mb-3">Change the way random aliases are generated by default.</div>
|
||||
<form method="post" class="form-inline">
|
||||
|
||||
<div class="mt-3 mb-1">Change the way random aliases are generated by default.</div>
|
||||
<form method="post" action="#random-alias" class="form-inline">
|
||||
<input type="hidden" name="form-name" value="change-alias-generator">
|
||||
<select class="form-control mr-sm-2" name="alias-generator-scheme">
|
||||
<option value="{{ AliasGeneratorEnum.word.value }}"
|
||||
|
@ -148,6 +149,20 @@
|
|||
</select>
|
||||
<button class="btn btn-outline-primary">Update</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-3 mb-1">Select the domain for random aliases.</div>
|
||||
<form method="post" action="#random-alias" class="form-inline">
|
||||
<input type="hidden" name="form-name" value="change-random-alias-default-domain">
|
||||
<select class="form-control mr-sm-2" name="random-alias-default-domain">
|
||||
{% for is_public, domain in current_user.available_domains_for_random_alias() %}
|
||||
<option value="{{ domain }}"
|
||||
{% if current_user.default_random_alias_domain() == domain %} selected {% endif %} >
|
||||
{{ domain }} ({% if is_public %} SimpleLogin domain {% else %} your domain {% endif %})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-outline-primary">Update</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
Setup is done
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-single">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col mx-auto" style="max-width: 34rem">
|
||||
<div class="text-center">
|
||||
<img src="/static/images/setup-done.png">
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<h1>Setup is done!</h1>
|
||||
<h4>Now click on SimpleLogin button to create your alias!</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -50,8 +50,7 @@ def available_suffixes(user: User) -> [bool, str, str]:
|
|||
def custom_alias():
|
||||
# check if user has not exceeded the alias quota
|
||||
if not current_user.can_create_new_alias():
|
||||
# notify admin
|
||||
LOG.error("user %s tries to create custom alias", current_user)
|
||||
LOG.warning("user %s tries to create custom alias", current_user)
|
||||
flash(
|
||||
"You have reached free plan limit, please upgrade to create new aliases",
|
||||
"warning",
|
||||
|
@ -95,7 +94,7 @@ def custom_alias():
|
|||
flash("Alias creation time is expired, please retry", "warning")
|
||||
return redirect(url_for("dashboard.custom_alias"))
|
||||
except Exception:
|
||||
LOG.error("Alias suffix is tampered, user %s", current_user)
|
||||
LOG.exception("Alias suffix is tampered, user %s", current_user)
|
||||
flash("Unknown error, refresh the page", "error")
|
||||
return redirect(url_for("dashboard.custom_alias"))
|
||||
|
||||
|
@ -182,20 +181,20 @@ def verify_prefix_suffix(user, alias_prefix, alias_suffix) -> bool:
|
|||
alias_domain not in user_custom_domains
|
||||
and alias_domain not in ALIAS_DOMAINS
|
||||
):
|
||||
LOG.error("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
LOG.exception("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
return False
|
||||
else:
|
||||
if alias_domain not in user_custom_domains:
|
||||
LOG.error("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
LOG.exception("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
return False
|
||||
else:
|
||||
if not alias_suffix.startswith("."):
|
||||
LOG.error("User %s submits a wrong alias suffix %s", user, alias_suffix)
|
||||
LOG.exception("User %s submits a wrong alias suffix %s", user, alias_suffix)
|
||||
return False
|
||||
|
||||
full_alias = alias_prefix + alias_suffix
|
||||
if not email_belongs_to_alias_domains(full_alias):
|
||||
LOG.error(
|
||||
LOG.exception(
|
||||
"Alias suffix should end with one of the alias domains %s",
|
||||
user,
|
||||
alias_suffix,
|
||||
|
@ -204,7 +203,7 @@ def verify_prefix_suffix(user, alias_prefix, alias_suffix) -> bool:
|
|||
|
||||
random_word_part = alias_suffix[1 : alias_suffix.find("@")]
|
||||
if not word_exist(random_word_part):
|
||||
LOG.error(
|
||||
LOG.exception(
|
||||
"alias suffix %s needs to start with a random word, user %s",
|
||||
alias_suffix,
|
||||
user,
|
||||
|
|
|
@ -56,7 +56,7 @@ def fido_setup():
|
|||
try:
|
||||
fido_credential = fido_reg_response.verify()
|
||||
except Exception as e:
|
||||
LOG.error(f"An error occurred in WebAuthn registration process: {e}")
|
||||
LOG.exception(f"An error occurred in WebAuthn registration process: {e}")
|
||||
flash("Key registration failed.", "warning")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ from wtforms import StringField, validators
|
|||
from wtforms.fields.html5 import EmailField
|
||||
|
||||
from app import s3, email_utils
|
||||
from app.config import URL
|
||||
from app.config import URL, FIRST_ALIAS_DOMAIN
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.email_utils import (
|
||||
email_domain_can_be_used_as_mailbox,
|
||||
|
@ -31,6 +31,7 @@ from app.models import (
|
|||
AliasGeneratorEnum,
|
||||
ManualSubscription,
|
||||
SenderFormatEnum,
|
||||
PublicDomain,
|
||||
)
|
||||
from app.utils import random_string
|
||||
|
||||
|
@ -159,6 +160,44 @@ def setting():
|
|||
flash("Your preference has been updated", "success")
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
|
||||
elif request.form.get("form-name") == "change-random-alias-default-domain":
|
||||
default_domain = request.form.get("random-alias-default-domain")
|
||||
|
||||
if default_domain:
|
||||
public_domain = PublicDomain.get_by(domain=default_domain)
|
||||
if public_domain:
|
||||
# make sure only default_random_alias_domain_id or default_random_alias_public_domain_id is set
|
||||
current_user.default_random_alias_public_domain_id = (
|
||||
public_domain.id
|
||||
)
|
||||
current_user.default_random_alias_domain_id = None
|
||||
else:
|
||||
custom_domain = CustomDomain.get_by(domain=default_domain)
|
||||
if custom_domain:
|
||||
# sanity check
|
||||
if (
|
||||
custom_domain.user_id != current_user.id
|
||||
or not custom_domain.verified
|
||||
):
|
||||
LOG.exception(
|
||||
"%s cannot use domain %s", current_user, default_domain
|
||||
)
|
||||
else:
|
||||
# make sure only default_random_alias_domain_id or
|
||||
# default_random_alias_public_domain_id is set
|
||||
current_user.default_random_alias_domain_id = (
|
||||
custom_domain.id
|
||||
)
|
||||
current_user.default_random_alias_public_domain_id = None
|
||||
|
||||
else:
|
||||
current_user.default_random_alias_domain_id = None
|
||||
current_user.default_random_alias_public_domain_id = None
|
||||
|
||||
db.session.commit()
|
||||
flash("Your preference has been updated", "success")
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
|
||||
elif request.form.get("form-name") == "change-sender-format":
|
||||
sender_format = int(request.form.get("sender-format"))
|
||||
if SenderFormatEnum.has_value(sender_format):
|
||||
|
@ -215,6 +254,7 @@ def setting():
|
|||
pending_email=pending_email,
|
||||
AliasGeneratorEnum=AliasGeneratorEnum,
|
||||
manual_sub=manual_sub,
|
||||
FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
from flask import render_template
|
||||
from flask_login import login_required
|
||||
|
||||
from app.dashboard.base import dashboard_bp
|
||||
|
||||
|
||||
@dashboard_bp.route("/setup_done", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def setup_done():
|
||||
return render_template("dashboard/setup_done.html")
|
|
@ -9,10 +9,10 @@
|
|||
<label class="form-label">Authorization endpoint</label>
|
||||
|
||||
<div class="input-group mt-2">
|
||||
<input type="text" disabled value="https://app.simplelogin.io/oauth2/authorize" class="form-control">
|
||||
<input type="text" disabled value="{{ URL + "/oauth2/authorize" }}" class="form-control">
|
||||
<span class="input-group-append">
|
||||
<button
|
||||
data-clipboard-text="https://app.simplelogin.io/oauth2/authorize"
|
||||
data-clipboard-text="{{ URL + "/oauth2/authorize" }}"
|
||||
class="clipboard btn btn-primary" type="button">
|
||||
<i class="fe fe-clipboard"></i>
|
||||
</button>
|
||||
|
@ -24,10 +24,10 @@
|
|||
<label class="form-label">Token endpoint</label>
|
||||
|
||||
<div class="input-group mt-2">
|
||||
<input type="text" disabled value="https://app.simplelogin.io/oauth2/token" class="form-control">
|
||||
<input type="text" disabled value="{{ URL + "/oauth2/token" }}" class="form-control">
|
||||
<span class="input-group-append">
|
||||
<button
|
||||
data-clipboard-text="https://app.simplelogin.io/oauth2/token"
|
||||
data-clipboard-text="{{ URL + "/oauth2/token" }}"
|
||||
class="clipboard btn btn-primary" type="button">
|
||||
<i class="fe fe-clipboard"></i>
|
||||
</button>
|
||||
|
@ -39,10 +39,10 @@
|
|||
<label class="form-label">UserInfo endpoint</label>
|
||||
|
||||
<div class="input-group mt-2">
|
||||
<input type="text" disabled value="https://app.simplelogin.io/oauth2/userinfo" class="form-control">
|
||||
<input type="text" disabled value="{{ URL + "/oauth2/userinfo" }}" class="form-control">
|
||||
<span class="input-group-append">
|
||||
<button
|
||||
data-clipboard-text="https://app.simplelogin.io/oauth2/userinfo"
|
||||
data-clipboard-text="{{ URL + "/oauth2/userinfo" }}"
|
||||
class="clipboard btn btn-primary" type="button">
|
||||
<i class="fe fe-clipboard"></i>
|
||||
</button>
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
<label class="form-label col">Authorized Redirect URIs</label>
|
||||
<p class="col text-right">
|
||||
<small class="text-muted">
|
||||
<em>redirect_uri</em> must be <b>HTTPS</b> for security reason.
|
||||
<b>redirect_uri</b> must be <b>HTTPS</b> for security reason.
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
|
@ -52,20 +52,20 @@
|
|||
<div class="alert alert-warning alert-dismissible fade show mb-4" role="alert">
|
||||
<p>
|
||||
You haven't added any <a href="https://www.oauth.com/oauth2-servers/redirect-uris/">redirect_uri</a>,
|
||||
that is the url that will receive the <em>code</em> or <em>access-token</em> in OAuth2 flow.
|
||||
that is the url that will receive the <b>code</b> or <b>access-token</b> in OAuth2 flow.
|
||||
</p>
|
||||
<p>
|
||||
There's NO NEED to add <em>http://localhost:*</em> as by default,
|
||||
SimpleLogin <em>whitelists</em> localhost (unlike Facebook).
|
||||
SimpleLogin <b>whitelists</b> localhost (unlike Facebook).
|
||||
</p>
|
||||
<p>
|
||||
You DO need to add your <em>redirect_uri</em> once your website/app goes live (i.e. deployed on production).
|
||||
You DO need to add your <b>redirect_uri</b> once your website/app goes live (i.e. deployed on production).
|
||||
</p>
|
||||
<p>
|
||||
The <em>redirect_uri</em> needs to be <b>HTTPS</b> for security reason.
|
||||
The <b>redirect_uri</b> needs to be <b>HTTPS</b> for security reason.
|
||||
</p>
|
||||
<p>
|
||||
Start by adding your first <em>redirect_uri</em> here 👇
|
||||
Start by adding your first <b>redirect_uri</b> here 👇
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -7,24 +7,25 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block default_content %}
|
||||
<h1>Create new app</h1>
|
||||
<p>An app can be a website, a SPA webapp or a mobile application</p>
|
||||
<small>Let's get started integrating SimpleLogin into your app! </small>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="h3">Create new app</h1>
|
||||
<p>An app can be a website, a SPA webapp or a mobile application</p>
|
||||
<p>Let's get started integrating SimpleLogin into your app! </p>
|
||||
|
||||
<hr>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{{ form.csrf_token }}
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{{ form.csrf_token }}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Display Name</label>
|
||||
{{ form.name(class="form-control",
|
||||
<div class="form-group">
|
||||
<label class="form-label">Display Name</label>
|
||||
{{ form.name(class="form-control",
|
||||
placeholder="Usually your product name, e.g. my-super-app.com") }}
|
||||
{{ render_field_errors(form.name) }}
|
||||
{{ render_field_errors(form.name) }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -464,7 +464,7 @@ def get_addrs_from_header(msg: Message, header) -> [str]:
|
|||
return [r for r in ret if r]
|
||||
|
||||
|
||||
def get_spam_info(msg: Message) -> (bool, str):
|
||||
def get_spam_info(msg: Message, max_score=None) -> (bool, str):
|
||||
"""parse SpamAssassin header to detect whether a message is classified as spam.
|
||||
Return (is spam, spam status detail)
|
||||
The header format is
|
||||
|
@ -476,10 +476,32 @@ def get_spam_info(msg: Message) -> (bool, str):
|
|||
if not spamassassin_status:
|
||||
return False, ""
|
||||
|
||||
# yes or no
|
||||
spamassassin_answer = spamassassin_status[: spamassassin_status.find(",")]
|
||||
return get_spam_from_header(spamassassin_status, max_score=max_score)
|
||||
|
||||
return spamassassin_answer.lower() == "yes", spamassassin_status
|
||||
|
||||
def get_spam_from_header(spam_status_header, max_score=None) -> (bool, str):
|
||||
"""get spam info from X-Spam-Status header
|
||||
Return (is spam, spam status detail).
|
||||
The spam_status_header has the following format
|
||||
```No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
|
||||
DKIM_VALID_AU,RCVD_IN_DNSWL_BLOCKED,RCVD_IN_MSPIKE_H2,SPF_PASS,
|
||||
URIBL_BLOCKED autolearn=unavailable autolearn_force=no version=3.4.2```
|
||||
"""
|
||||
# yes or no
|
||||
spamassassin_answer = spam_status_header[: spam_status_header.find(",")]
|
||||
|
||||
if max_score:
|
||||
# spam score
|
||||
# get the score section "score=-0.1"
|
||||
score_section = (
|
||||
spam_status_header[spam_status_header.find(",") + 1 :].strip().split(" ")[0]
|
||||
)
|
||||
score = float(score_section[len("score=") :])
|
||||
if score >= max_score:
|
||||
LOG.warning("Spam score %s exceeds %s", score, max_score)
|
||||
return True, spam_status_header
|
||||
|
||||
return spamassassin_answer.lower() == "yes", spam_status_header
|
||||
|
||||
|
||||
def parseaddr_unicode(addr) -> (str, str):
|
||||
|
|
162
app/models.py
162
app/models.py
|
@ -2,7 +2,7 @@ import enum
|
|||
import random
|
||||
import uuid
|
||||
from email.utils import formataddr
|
||||
from typing import List
|
||||
from typing import List, Tuple
|
||||
|
||||
import arrow
|
||||
import bcrypt
|
||||
|
@ -166,13 +166,19 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||
# Fields for WebAuthn
|
||||
fido_uuid = db.Column(db.String(), nullable=True, unique=True)
|
||||
|
||||
def fido_enabled(self) -> bool:
|
||||
if self.fido_uuid is not None:
|
||||
return True
|
||||
return False
|
||||
# the default domain that's used when user creates a new random alias
|
||||
# default_random_alias_domain_id XOR default_random_alias_public_domain_id
|
||||
default_random_alias_domain_id = db.Column(
|
||||
db.ForeignKey("custom_domain.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
|
||||
def two_factor_authentication_enabled(self) -> bool:
|
||||
return self.enable_otp or self.fido_enabled()
|
||||
default_random_alias_public_domain_id = db.Column(
|
||||
db.ForeignKey("public_domain.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
|
||||
# some users could have lifetime premium
|
||||
lifetime = db.Column(db.Boolean, default=False, nullable=False, server_default="0")
|
||||
|
@ -221,6 +227,9 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||
|
||||
default_mailbox = db.relationship("Mailbox", foreign_keys=[default_mailbox_id])
|
||||
|
||||
# user can set a more strict max_spam score to block spams more aggressively
|
||||
max_spam_score = db.Column(db.Integer, nullable=True)
|
||||
|
||||
@classmethod
|
||||
def create(cls, email, name, password=None, **kwargs):
|
||||
user: User = super(User, cls).create(email=email, name=name, **kwargs)
|
||||
|
@ -286,6 +295,26 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||
|
||||
return False
|
||||
|
||||
def is_paid(self) -> bool:
|
||||
"""same as _lifetime_or_active_subscription but not include free manual subscription"""
|
||||
sub: Subscription = self.get_subscription()
|
||||
if sub:
|
||||
return True
|
||||
|
||||
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id)
|
||||
if apple_sub and apple_sub.is_valid():
|
||||
return True
|
||||
|
||||
manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
|
||||
if (
|
||||
manual_sub
|
||||
and not manual_sub.is_giveaway
|
||||
and manual_sub.end_at > arrow.now()
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def in_trial(self):
|
||||
"""return True if user does not have lifetime licence or an active subscription AND is in trial period"""
|
||||
if self._lifetime_or_active_subscription():
|
||||
|
@ -430,6 +459,63 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||
def nb_directory(self):
|
||||
return Directory.query.filter_by(user_id=self.id).count()
|
||||
|
||||
def has_custom_domain(self):
|
||||
return CustomDomain.filter_by(user_id=self.id, verified=True).count() > 0
|
||||
|
||||
def custom_domains(self):
|
||||
return CustomDomain.filter_by(user_id=self.id, verified=True).all()
|
||||
|
||||
def available_domains_for_random_alias(self) -> List[Tuple[bool, str]]:
|
||||
"""Return available domains for user to create random aliases
|
||||
Each result record contains:
|
||||
- whether the domain is public (i.e. belongs to SimpleLogin)
|
||||
- the domain
|
||||
"""
|
||||
res = []
|
||||
for public_domain in PublicDomain.query.all():
|
||||
res.append((True, public_domain.domain))
|
||||
|
||||
for custom_domain in CustomDomain.filter_by(
|
||||
user_id=self.id, verified=True
|
||||
).all():
|
||||
res.append((False, custom_domain.domain))
|
||||
|
||||
return res
|
||||
|
||||
def default_random_alias_domain(self) -> str:
|
||||
"""return the domain used for the random alias"""
|
||||
if self.default_random_alias_domain_id:
|
||||
custom_domain = CustomDomain.get(self.default_random_alias_domain_id)
|
||||
# sanity check
|
||||
if (
|
||||
not custom_domain
|
||||
or not custom_domain.verified
|
||||
or custom_domain.user_id != self.id
|
||||
):
|
||||
LOG.exception("Problem with %s default random alias domain", self)
|
||||
return FIRST_ALIAS_DOMAIN
|
||||
|
||||
return custom_domain.domain
|
||||
|
||||
if self.default_random_alias_public_domain_id:
|
||||
public_domain = PublicDomain.get(self.default_random_alias_public_domain_id)
|
||||
# sanity check
|
||||
if not public_domain:
|
||||
LOG.exception("Problem with %s public random alias domain", self)
|
||||
return FIRST_ALIAS_DOMAIN
|
||||
|
||||
return public_domain.domain
|
||||
|
||||
return FIRST_ALIAS_DOMAIN
|
||||
|
||||
def fido_enabled(self) -> bool:
|
||||
if self.fido_uuid is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
def two_factor_authentication_enabled(self) -> bool:
|
||||
return self.enable_otp or self.fido_enabled()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.id} {self.name} {self.email}>"
|
||||
|
||||
|
@ -646,17 +732,20 @@ class OauthToken(db.Model, ModelMixin):
|
|||
|
||||
|
||||
def generate_email(
|
||||
scheme: int = AliasGeneratorEnum.word.value, in_hex: bool = False
|
||||
scheme: int = AliasGeneratorEnum.word.value,
|
||||
in_hex: bool = False,
|
||||
alias_domain=FIRST_ALIAS_DOMAIN,
|
||||
) -> str:
|
||||
"""generate an email address that does not exist before
|
||||
:param alias_domain: the domain used to generate the alias.
|
||||
: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 + "@" + FIRST_ALIAS_DOMAIN
|
||||
random_email = name + "@" + alias_domain
|
||||
else:
|
||||
random_email = random_words() + "@" + FIRST_ALIAS_DOMAIN
|
||||
random_email = random_words() + "@" + alias_domain
|
||||
|
||||
random_email = random_email.lower().strip()
|
||||
|
||||
|
@ -730,6 +819,7 @@ class Alias(db.Model, ModelMixin):
|
|||
for m in self._mailboxes:
|
||||
ret.append(m)
|
||||
|
||||
ret = [mb for mb in ret if mb.verified]
|
||||
ret = sorted(ret, key=lambda mb: mb.email)
|
||||
|
||||
return ret
|
||||
|
@ -790,14 +880,33 @@ class Alias(db.Model, ModelMixin):
|
|||
note: str = None,
|
||||
):
|
||||
"""create a new random alias"""
|
||||
random_email = generate_email(scheme=scheme, in_hex=in_hex)
|
||||
return Alias.create(
|
||||
custom_domain = None
|
||||
|
||||
if user.default_random_alias_domain_id:
|
||||
custom_domain = CustomDomain.get(user.default_random_alias_domain_id)
|
||||
random_email = generate_email(
|
||||
scheme=scheme, in_hex=in_hex, alias_domain=custom_domain.domain
|
||||
)
|
||||
elif user.default_random_alias_public_domain_id:
|
||||
public_domain = PublicDomain.get(user.default_random_alias_public_domain_id)
|
||||
random_email = generate_email(
|
||||
scheme=scheme, in_hex=in_hex, alias_domain=public_domain.domain
|
||||
)
|
||||
else:
|
||||
random_email = generate_email(scheme=scheme, in_hex=in_hex)
|
||||
|
||||
alias = Alias.create(
|
||||
user_id=user.id,
|
||||
email=random_email,
|
||||
mailbox_id=user.default_mailbox_id,
|
||||
note=note,
|
||||
)
|
||||
|
||||
if custom_domain:
|
||||
alias.custom_domain_id = custom_domain.id
|
||||
|
||||
return alias
|
||||
|
||||
def mailbox_email(self):
|
||||
if self.mailbox_id:
|
||||
return self.mailbox.email
|
||||
|
@ -1247,7 +1356,7 @@ class CustomDomain(db.Model, ModelMixin):
|
|||
# an alias is created automatically the first time it receives an email
|
||||
catch_all = db.Column(db.Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
user = db.relationship(User)
|
||||
user = db.relationship(User, foreign_keys=[user_id])
|
||||
|
||||
def nb_alias(self):
|
||||
return Alias.filter_by(custom_domain_id=self.id).count()
|
||||
|
@ -1304,7 +1413,7 @@ class Directory(db.Model, ModelMixin):
|
|||
db.session.commit()
|
||||
# this can happen when a previously deleted alias is re-created via catch-all or directory feature
|
||||
except IntegrityError:
|
||||
LOG.error("Some aliases have been added before to DeletedAlias")
|
||||
LOG.exception("Some aliases have been added before to DeletedAlias")
|
||||
db.session.rollback()
|
||||
|
||||
cls.query.filter(cls.id == obj_id).delete()
|
||||
|
@ -1340,6 +1449,13 @@ class Mailbox(db.Model, ModelMixin):
|
|||
pgp_public_key = db.Column(db.Text, nullable=True)
|
||||
pgp_finger_print = db.Column(db.String(512), nullable=True)
|
||||
|
||||
# incremented when a check is failed on the mailbox
|
||||
# alert when the number exceeds a threshold
|
||||
# used in sanity_check()
|
||||
nb_failed_checks = db.Column(
|
||||
db.Integer, default=0, server_default="0", nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (db.UniqueConstraint("user_id", "email", name="uq_mailbox_user"),)
|
||||
|
||||
user = db.relationship(User, foreign_keys=[user_id])
|
||||
|
@ -1367,7 +1483,7 @@ class Mailbox(db.Model, ModelMixin):
|
|||
db.session.commit()
|
||||
# this can happen when a previously deleted alias is re-created via catch-all or directory feature
|
||||
except IntegrityError:
|
||||
LOG.error("Some aliases have been added before to DeletedAlias")
|
||||
LOG.exception("Some aliases have been added before to DeletedAlias")
|
||||
db.session.rollback()
|
||||
|
||||
cls.query.filter(cls.id == obj_id).delete()
|
||||
|
@ -1450,9 +1566,17 @@ class Referral(db.Model, ModelMixin):
|
|||
|
||||
user = db.relationship(User, foreign_keys=[user_id])
|
||||
|
||||
def nb_user(self):
|
||||
def nb_user(self) -> int:
|
||||
return User.filter_by(referral_id=self.id, activated=True).count()
|
||||
|
||||
def nb_paid_user(self) -> int:
|
||||
res = 0
|
||||
for user in User.filter_by(referral_id=self.id, activated=True):
|
||||
if user.is_paid():
|
||||
res += 1
|
||||
|
||||
return res
|
||||
|
||||
def link(self):
|
||||
return f"{LANDING_PAGE_URL}?slref={self.code}"
|
||||
|
||||
|
@ -1547,3 +1671,9 @@ class Notification(db.Model, ModelMixin):
|
|||
|
||||
# whether user has marked the notification as read
|
||||
read = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class PublicDomain(db.Model, ModelMixin):
|
||||
"""SimpleLogin domains that all users can use"""
|
||||
|
||||
domain = db.Column(db.String(128), unique=True, nullable=False)
|
||||
|
|
|
@ -156,11 +156,11 @@ def authorize():
|
|||
try:
|
||||
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
||||
except SignatureExpired:
|
||||
LOG.error("Alias creation time expired for %s", current_user)
|
||||
LOG.warning("Alias creation time expired for %s", current_user)
|
||||
flash("Alias creation time is expired, please retry", "warning")
|
||||
return redirect(request.url)
|
||||
except Exception:
|
||||
LOG.error("Alias suffix is tampered, user %s", current_user)
|
||||
LOG.exception("Alias suffix is tampered, user %s", current_user)
|
||||
flash("Unknown error, refresh the page", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
|
@ -178,7 +178,7 @@ def authorize():
|
|||
or DeletedAlias.get_by(email=full_alias)
|
||||
or DomainDeletedAlias.get_by(email=full_alias)
|
||||
):
|
||||
LOG.error("alias %s already used, very rare!", full_alias)
|
||||
LOG.exception("alias %s already used, very rare!", full_alias)
|
||||
flash(f"Alias {full_alias} already used", "error")
|
||||
return redirect(request.url)
|
||||
else:
|
||||
|
|
|
@ -71,7 +71,7 @@ def cancel_subscription(subscription_id: int) -> bool:
|
|||
)
|
||||
res = r.json()
|
||||
if not res["success"]:
|
||||
LOG.error(
|
||||
LOG.exception(
|
||||
f"cannot cancel subscription {subscription_id}, paddle response: {res}"
|
||||
)
|
||||
|
||||
|
@ -90,7 +90,7 @@ def change_plan(subscription_id: int, plan_id) -> bool:
|
|||
)
|
||||
res = r.json()
|
||||
if not res["success"]:
|
||||
LOG.error(
|
||||
LOG.exception(
|
||||
f"cannot change subscription {subscription_id} to {plan_id}, paddle response: {res}"
|
||||
)
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@ def encrypt_file(data: BytesIO, fingerprint: str) -> str:
|
|||
|
||||
# todo
|
||||
if mem_usage > 300:
|
||||
LOG.error("Force exit")
|
||||
LOG.exception("Force exit")
|
||||
hard_exit()
|
||||
|
||||
r = gpg.encrypt_file(data, fingerprint, always_trust=True)
|
||||
|
|
17
cron.py
17
cron.py
|
@ -279,20 +279,31 @@ def sanity_check():
|
|||
# hack to not query DNS too often
|
||||
sleep(1)
|
||||
if not email_domain_can_be_used_as_mailbox(mailbox.email):
|
||||
LOG.error(
|
||||
mailbox.nb_failed_checks += 1
|
||||
# alert if too much fail
|
||||
if mailbox.nb_failed_checks > 10:
|
||||
log_func = LOG.exception
|
||||
else:
|
||||
log_func = LOG.warning
|
||||
|
||||
log_func(
|
||||
"issue with mailbox %s domain. #alias %s, nb email log %s",
|
||||
mailbox,
|
||||
mailbox.nb_alias(),
|
||||
mailbox.nb_email_log(),
|
||||
)
|
||||
else: # reset nb check
|
||||
mailbox.nb_failed_checks = 0
|
||||
|
||||
db.session.commit()
|
||||
|
||||
for user in User.filter_by(activated=True).all():
|
||||
if user.email.lower() != user.email:
|
||||
LOG.error("%s does not have lowercase email", user)
|
||||
LOG.exception("%s does not have lowercase email", user)
|
||||
|
||||
for mailbox in Mailbox.filter_by(verified=True).all():
|
||||
if mailbox.email.lower() != mailbox.email:
|
||||
LOG.error("%s does not have lowercase email", mailbox)
|
||||
LOG.exception("%s does not have lowercase email", mailbox)
|
||||
|
||||
LOG.d("Finish sanity check")
|
||||
|
||||
|
|
|
@ -138,7 +138,7 @@ def get_or_create_contact(
|
|||
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(
|
||||
LOG.exception(
|
||||
"Cannot parse contact from from_header:%s, mail_from:%s",
|
||||
contact_from_header,
|
||||
mail_from,
|
||||
|
@ -373,7 +373,7 @@ def handle_forward(
|
|||
alias = try_auto_create(address)
|
||||
if not alias:
|
||||
LOG.d("alias %s cannot be created on-the-fly, return 550", address)
|
||||
return [(False, "550 SL E3")]
|
||||
return [(False, "550 SL E3 Email not exist")]
|
||||
|
||||
contact = get_or_create_contact(msg["From"], envelope.mail_from, alias)
|
||||
email_log = EmailLog.create(contact_id=contact.id, user_id=contact.user_id)
|
||||
|
@ -413,7 +413,7 @@ def forward_email_to_mailbox(
|
|||
|
||||
# sanity check: make sure mailbox is not actually an alias
|
||||
if get_email_domain_part(alias.email) == get_email_domain_part(mailbox.email):
|
||||
LOG.error(
|
||||
LOG.exception(
|
||||
"Mailbox has the same domain as alias. %s -> %s -> %s",
|
||||
contact,
|
||||
alias,
|
||||
|
@ -421,14 +421,14 @@ def forward_email_to_mailbox(
|
|||
)
|
||||
return False, "550 SL E14"
|
||||
|
||||
is_spam, spam_status = get_spam_info(msg)
|
||||
is_spam, spam_status = get_spam_info(msg, max_score=user.max_spam_score)
|
||||
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"
|
||||
return False, "550 SL E1 Email detected as spam"
|
||||
|
||||
# create PGP email if needed
|
||||
if mailbox.pgp_finger_print and user.is_premium() and not alias.disable_pgp:
|
||||
|
@ -436,11 +436,11 @@ def forward_email_to_mailbox(
|
|||
try:
|
||||
msg = prepare_pgp_message(msg, mailbox.pgp_finger_print)
|
||||
except PGPException:
|
||||
LOG.error(
|
||||
LOG.exception(
|
||||
"Cannot encrypt message %s -> %s. %s %s", contact, alias, mailbox, user
|
||||
)
|
||||
# so the client can retry later
|
||||
return False, "421 SL E12"
|
||||
return False, "421 SL E12 Retry later"
|
||||
|
||||
# add custom header
|
||||
add_or_replace_header(msg, "X-SimpleLogin-Type", "Forward")
|
||||
|
@ -524,7 +524,7 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str
|
|||
contact = Contact.get_by(reply_email=reply_email)
|
||||
if not contact:
|
||||
LOG.warning(f"No such forward-email with {reply_email} as reply-email")
|
||||
return False, "550 SL E4"
|
||||
return False, "550 SL E4 Email not exist"
|
||||
|
||||
alias = contact.alias
|
||||
address: str = contact.alias.email
|
||||
|
@ -630,23 +630,44 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str
|
|||
try:
|
||||
msg = prepare_pgp_message(msg, contact.pgp_finger_print)
|
||||
except PGPException:
|
||||
LOG.error(
|
||||
LOG.exception(
|
||||
"Cannot encrypt message %s -> %s. %s %s", alias, contact, mailbox, user
|
||||
)
|
||||
# so the client can retry later
|
||||
return False, "421 SL E13"
|
||||
return False, "421 SL E13 Retry later"
|
||||
|
||||
smtp.sendmail(
|
||||
alias.email,
|
||||
contact.website_email,
|
||||
msg.as_bytes(),
|
||||
envelope.mail_options,
|
||||
envelope.rcpt_options,
|
||||
)
|
||||
try:
|
||||
smtp.sendmail(
|
||||
alias.email,
|
||||
contact.website_email,
|
||||
msg.as_bytes(),
|
||||
envelope.mail_options,
|
||||
envelope.rcpt_options,
|
||||
)
|
||||
except Exception:
|
||||
LOG.exception("Cannot send email from %s to %s", alias, contact)
|
||||
send_email(
|
||||
mailbox.email,
|
||||
f"Email cannot be sent to {contact.email} from {alias.email}",
|
||||
render(
|
||||
"transactional/reply-error.txt",
|
||||
user=user,
|
||||
alias=alias,
|
||||
contact=contact,
|
||||
contact_domain=get_email_domain_part(contact.email),
|
||||
),
|
||||
render(
|
||||
"transactional/reply-error.html",
|
||||
user=user,
|
||||
alias=alias,
|
||||
contact=contact,
|
||||
contact_domain=get_email_domain_part(contact.email),
|
||||
),
|
||||
)
|
||||
else:
|
||||
EmailLog.create(contact_id=contact.id, is_reply=True, user_id=contact.user_id)
|
||||
|
||||
EmailLog.create(contact_id=contact.id, is_reply=True, user_id=contact.user_id)
|
||||
db.session.commit()
|
||||
|
||||
return True, "250 Message accepted for delivery"
|
||||
|
||||
|
||||
|
@ -664,7 +685,7 @@ def spf_pass(
|
|||
try:
|
||||
r = spf.check2(i=ip, s=envelope.mail_from.lower(), h=None)
|
||||
except Exception:
|
||||
LOG.error("SPF error, mailbox %s, ip %s", mailbox.email, ip)
|
||||
LOG.exception("SPF error, mailbox %s, ip %s", mailbox.email, ip)
|
||||
else:
|
||||
# TODO: Handle temperr case (e.g. dns timeout)
|
||||
# only an absolute pass, or no SPF policy at all is 'valid'
|
||||
|
@ -803,7 +824,7 @@ def handle_bounce(contact: Contact, alias: Alias, msg: Message, user: User):
|
|||
mailbox_id = int(orig_msg[_MAILBOX_ID_HEADER])
|
||||
mailbox = Mailbox.get(mailbox_id)
|
||||
if not mailbox or mailbox.user_id != user.id:
|
||||
LOG.error(
|
||||
LOG.exception(
|
||||
"Tampered message mailbox_id %s, %s, %s, %s %s",
|
||||
mailbox_id,
|
||||
user,
|
||||
|
@ -983,18 +1004,18 @@ def handle_unsubscribe(envelope: Envelope):
|
|||
alias = Alias.get(alias_id)
|
||||
except Exception:
|
||||
LOG.warning("Cannot parse alias from subject %s", msg["Subject"])
|
||||
return "550 SL E8"
|
||||
return "550 SL E8 Wrongly formatted subject"
|
||||
|
||||
if not alias:
|
||||
LOG.warning("No such alias %s", alias_id)
|
||||
return "550 SL E9"
|
||||
return "550 SL E9 Email not exist"
|
||||
|
||||
# This sender cannot unsubscribe
|
||||
mail_from = envelope.mail_from.lower().strip()
|
||||
mailbox = Mailbox.get_by(user_id=alias.user_id, email=mail_from)
|
||||
if not mailbox or mailbox not in alias.mailboxes:
|
||||
LOG.d("%s cannot disable alias %s", envelope.mail_from, alias)
|
||||
return "550 SL E10"
|
||||
return "550 SL E10 unauthorized"
|
||||
|
||||
# Sender is owner of this alias
|
||||
alias.enabled = False
|
||||
|
@ -1073,11 +1094,18 @@ def handle(envelope: Envelope, smtp: SMTP) -> str:
|
|||
# Reply case
|
||||
# recipient starts with "reply+" or "ra+" (ra=reverse-alias) prefix
|
||||
if rcpt_to.startswith("reply+") or rcpt_to.startswith("ra+"):
|
||||
LOG.debug(">>> Reply phase %s -> %s", envelope.mail_from, rcpt_to)
|
||||
LOG.debug(
|
||||
">>> Reply phase %s(%s) -> %s", envelope.mail_from, msg["From"], rcpt_to
|
||||
)
|
||||
is_delivered, smtp_status = handle_reply(envelope, smtp, msg, rcpt_to)
|
||||
res.append((is_delivered, smtp_status))
|
||||
else: # Forward case
|
||||
LOG.debug(">>> Forward phase %s -> %s", envelope.mail_from, rcpt_to)
|
||||
LOG.debug(
|
||||
">>> Forward phase %s(%s) -> %s",
|
||||
envelope.mail_from,
|
||||
msg["From"],
|
||||
rcpt_to,
|
||||
)
|
||||
for is_delivered, smtp_status in handle_forward(
|
||||
envelope, smtp, msg, rcpt_to
|
||||
):
|
||||
|
|
|
@ -145,4 +145,8 @@ DISABLE_ONBOARDING=true
|
|||
|
||||
# By default use postfix port 25. This param is used to override the Postfix port,
|
||||
# useful when using another SMTP server when developing locally
|
||||
# POSTFIX_PORT=1025
|
||||
# POSTFIX_PORT=1025
|
||||
|
||||
# set the 2 below variables to enable hCaptcha
|
||||
# HCAPTCHA_SECRET=very_long_string
|
||||
# HCAPTCHA_SITEKEY=00000000-0000-0000-0000-000000000000
|
23
init_app.py
23
init_app.py
|
@ -1,5 +1,6 @@
|
|||
"""Initial loading script"""
|
||||
from app.models import Mailbox, Contact
|
||||
from app.config import ALIAS_DOMAINS
|
||||
from app.models import Mailbox, Contact, PublicDomain
|
||||
from app.log import LOG
|
||||
from app.extensions import db
|
||||
from app.pgp_utils import load_public_key
|
||||
|
@ -14,7 +15,9 @@ def load_pgp_public_keys():
|
|||
|
||||
# sanity check
|
||||
if fingerprint != mailbox.pgp_finger_print:
|
||||
LOG.error("fingerprint %s different for mailbox %s", fingerprint, mailbox)
|
||||
LOG.exception(
|
||||
"fingerprint %s different for mailbox %s", fingerprint, mailbox
|
||||
)
|
||||
mailbox.pgp_finger_print = fingerprint
|
||||
db.session.commit()
|
||||
|
||||
|
@ -24,7 +27,9 @@ def load_pgp_public_keys():
|
|||
|
||||
# sanity check
|
||||
if fingerprint != contact.pgp_finger_print:
|
||||
LOG.error("fingerprint %s different for contact %s", fingerprint, contact)
|
||||
LOG.exception(
|
||||
"fingerprint %s different for contact %s", fingerprint, contact
|
||||
)
|
||||
contact.pgp_finger_print = fingerprint
|
||||
|
||||
db.session.commit()
|
||||
|
@ -32,8 +37,20 @@ def load_pgp_public_keys():
|
|||
LOG.d("Finish load_pgp_public_keys")
|
||||
|
||||
|
||||
def add_public_domains():
|
||||
for alias_domain in ALIAS_DOMAINS:
|
||||
if PublicDomain.get_by(domain=alias_domain):
|
||||
LOG.d("%s is already a public domain", alias_domain)
|
||||
else:
|
||||
LOG.info("Add %s to public domain", alias_domain)
|
||||
PublicDomain.create(domain=alias_domain)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
load_pgp_public_keys()
|
||||
add_public_domains()
|
||||
|
|
|
@ -130,6 +130,6 @@ if __name__ == "__main__":
|
|||
onboarding_browser_extension(user)
|
||||
|
||||
else:
|
||||
LOG.error("Unknown job name %s", job.name)
|
||||
LOG.exception("Unknown job name %s", job.name)
|
||||
|
||||
time.sleep(10)
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: a3c9a43e41f4
|
||||
Revises: a5b4dc311a89
|
||||
Create Date: 2020-06-25 13:02:21.128994
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a3c9a43e41f4'
|
||||
down_revision = 'a5b4dc311a89'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('default_random_alias_domain_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(None, 'users', 'custom_domain', ['default_random_alias_domain_id'], ['id'])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'users', type_='foreignkey')
|
||||
op.drop_column('users', 'default_random_alias_domain_id')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,29 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 7128f87af701
|
||||
Revises: a3c9a43e41f4
|
||||
Create Date: 2020-06-28 11:18:22.765690
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7128f87af701'
|
||||
down_revision = 'a3c9a43e41f4'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('mailbox', sa.Column('nb_failed_checks', sa.Integer(), server_default='0', nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('mailbox', 'nb_failed_checks')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,44 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 270d598c51e3
|
||||
Revises: 7128f87af701
|
||||
Create Date: 2020-07-04 23:32:25.297082
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '270d598c51e3'
|
||||
down_revision = '7128f87af701'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('public_domain',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||
sa.Column('domain', sa.String(length=128), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('domain')
|
||||
)
|
||||
op.add_column('users', sa.Column('default_random_alias_public_domain_id', sa.Integer(), nullable=True))
|
||||
op.drop_constraint('users_default_random_alias_domain_id_fkey', 'users', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'users', 'custom_domain', ['default_random_alias_domain_id'], ['id'], ondelete='SET NULL')
|
||||
op.create_foreign_key(None, 'users', 'public_domain', ['default_random_alias_public_domain_id'], ['id'], ondelete='SET NULL')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'users', type_='foreignkey')
|
||||
op.drop_constraint(None, 'users', type_='foreignkey')
|
||||
op.create_foreign_key('users_default_random_alias_domain_id_fkey', 'users', 'custom_domain', ['default_random_alias_domain_id'], ['id'])
|
||||
op.drop_column('users', 'default_random_alias_public_domain_id')
|
||||
op.drop_table('public_domain')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,29 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: b77ab8c47cc7
|
||||
Revises: 270d598c51e3
|
||||
Create Date: 2020-07-23 11:08:34.913760
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b77ab8c47cc7'
|
||||
down_revision = '270d598c51e3'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('max_spam_score', sa.Integer(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'max_spam_score')
|
||||
# ### end Alembic commands ###
|
43
server.py
43
server.py
|
@ -1,11 +1,22 @@
|
|||
from datetime import timedelta
|
||||
|
||||
import arrow
|
||||
import flask_profiler
|
||||
import os
|
||||
import sentry_sdk
|
||||
import ssl
|
||||
from flask import Flask, redirect, url_for, render_template, request, jsonify, flash
|
||||
from flask import (
|
||||
Flask,
|
||||
redirect,
|
||||
url_for,
|
||||
render_template,
|
||||
request,
|
||||
jsonify,
|
||||
flash,
|
||||
session,
|
||||
)
|
||||
from flask_admin import Admin
|
||||
from flask_cors import cross_origin
|
||||
from flask_cors import cross_origin, CORS
|
||||
from flask_login import current_user
|
||||
from sentry_sdk.integrations.aiohttp import AioHttpIntegration
|
||||
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||
|
@ -28,6 +39,7 @@ from app.config import (
|
|||
FLASK_PROFILER_PASSWORD,
|
||||
SENTRY_FRONT_END_DSN,
|
||||
FIRST_ALIAS_DOMAIN,
|
||||
SESSION_COOKIE_NAME,
|
||||
)
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.developer.base import developer_bp
|
||||
|
@ -52,6 +64,7 @@ from app.models import (
|
|||
Referral,
|
||||
AliasMailbox,
|
||||
Notification,
|
||||
PublicDomain,
|
||||
)
|
||||
from app.monitor.base import monitor_bp
|
||||
from app.oauth.base import oauth_bp
|
||||
|
@ -89,7 +102,7 @@ def create_app() -> Flask:
|
|||
app.config["TEMPLATES_AUTO_RELOAD"] = True
|
||||
|
||||
# to avoid conflict with other cookie
|
||||
app.config["SESSION_COOKIE_NAME"] = "slapp"
|
||||
app.config["SESSION_COOKIE_NAME"] = SESSION_COOKIE_NAME
|
||||
if URL.startswith("https"):
|
||||
app.config["SESSION_COOKIE_SECURE"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
|
@ -122,6 +135,16 @@ def create_app() -> Flask:
|
|||
}
|
||||
flask_profiler.init_app(app)
|
||||
|
||||
# enable CORS on /api endpoints
|
||||
cors = CORS(app, resources={r"/api/*": {"origins": "*"}})
|
||||
|
||||
# set session to permanent so user stays signed in after quitting the browser
|
||||
# the cookie is valid for 7 days
|
||||
@app.before_request
|
||||
def make_session_permanent():
|
||||
session.permanent = True
|
||||
app.permanent_session_lifetime = timedelta(days=7)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
@ -179,7 +202,7 @@ def fake_data():
|
|||
)
|
||||
db.session.commit()
|
||||
|
||||
for i in range(31):
|
||||
for i in range(3):
|
||||
if i % 2 == 0:
|
||||
a = Alias.create(
|
||||
email=f"e{i}@{FIRST_ALIAS_DOMAIN}", user_id=user.id, mailbox_id=m1.id
|
||||
|
@ -264,6 +287,10 @@ def fake_data():
|
|||
)
|
||||
db.session.commit()
|
||||
|
||||
for d in ["d1.localhost", "d2.localhost"]:
|
||||
PublicDomain.create(domain=d)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
|
@ -371,7 +398,7 @@ def setup_error_page(app):
|
|||
|
||||
@app.errorhandler(429)
|
||||
def forbidden(e):
|
||||
LOG.error("Client hit rate limit on path %s", request.path)
|
||||
LOG.warning("Client hit rate limit on path %s", request.path)
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify(error="Rate limit exceeded"), 429
|
||||
else:
|
||||
|
@ -431,7 +458,7 @@ def setup_paddle_callback(app: Flask):
|
|||
|
||||
# make sure the request comes from Paddle
|
||||
if not paddle_utils.verify_incoming_request(dict(request.form)):
|
||||
LOG.error(
|
||||
LOG.exception(
|
||||
"request not coming from paddle. Request data:%s", dict(request.form)
|
||||
)
|
||||
return "KO", 400
|
||||
|
@ -610,6 +637,6 @@ if __name__ == "__main__":
|
|||
context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
|
||||
context.load_cert_chain("local_data/cert.pem", "local_data/key.pem")
|
||||
|
||||
app.run(debug=True, host="0.0.0.0", port=7777, ssl_context=context)
|
||||
app.run(debug=True, port=7777, ssl_context=context)
|
||||
else:
|
||||
app.run(debug=True, host="0.0.0.0", port=7777)
|
||||
app.run(debug=True, port=7777)
|
||||
|
|
31
shell.py
31
shell.py
|
@ -114,6 +114,37 @@ def migrate_domain_trash():
|
|||
db.session.commit()
|
||||
|
||||
|
||||
def disable_mailbox(mailbox_id):
|
||||
"""disable a mailbox and all of its aliases"""
|
||||
mailbox = Mailbox.get(mailbox_id)
|
||||
mailbox.verified = False
|
||||
for alias in mailbox.aliases:
|
||||
alias.enabled = False
|
||||
|
||||
db.session.commit()
|
||||
|
||||
email_msg = f"""Hi,
|
||||
|
||||
Your mailbox {mailbox.email} cannot receive emails.
|
||||
To avoid forwarding emails to an invalid mailbox, we have disabled this mailbox along with all of its aliases.
|
||||
|
||||
If this is a mistake, please reply to this email.
|
||||
|
||||
Thanks,
|
||||
SimpleLogin team.
|
||||
"""
|
||||
|
||||
try:
|
||||
send_email(
|
||||
mailbox.user.email,
|
||||
f"{mailbox.email} is disabled",
|
||||
email_msg,
|
||||
email_msg.replace("\n", "<br>"),
|
||||
)
|
||||
except Exception:
|
||||
LOG.exception("Cannot send disable mailbox email to %s", mailbox.user)
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 5.8 KiB |
|
@ -4,7 +4,7 @@
|
|||
{{ render_text("Hi " + user.name) }}
|
||||
|
||||
{% call text() %}
|
||||
If you want to quickly create aliases without going to SimpleLogin website, you can do that with SimpleLogin
|
||||
If you want to quickly create aliases <b>without</b> going to SimpleLogin website, you can do that with SimpleLogin
|
||||
<a href="https://chrome.google.com/webstore/detail/simplelogin-your-anti-spa/dphilobhebphkdjbpfohgikllaljmgbn">Chrome</a>
|
||||
(or other Chromium-based browsers like Brave or Vivaldi),
|
||||
<a href="https://addons.mozilla.org/en-GB/firefox/addon/simplelogin/">Firefox</a> and
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{{ render_text("Hi " + (user.name or "")) }}
|
||||
|
||||
{% call text() %}
|
||||
Your email cannot be sent to <b>{{ contact.email }}</b> from your alias <b>{{ alias.email }}</b>.
|
||||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
Can you please verify <b>{{ contact.email }}</b> is a valid address?
|
||||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
Usually this is because the DNS record of <b>{{ contact_domain }}</b> does not exist.
|
||||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
You can check its DNS record on any DNS checker websites, for example https://mxtoolbox.com/SuperTool.aspx
|
||||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
Please let us know if you have any question. <br>
|
||||
Best, <br>
|
||||
SimpleLogin team.
|
||||
{% endcall %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
Hi {{user.name or ""}}
|
||||
|
||||
Your email cannot be sent to {{contact.email}} from your alias {{alias.email}}.
|
||||
|
||||
Can you please verify {{contact.email}} is a valid address?
|
||||
|
||||
Usually this is because the DNS record of {{contact_domain}} does not exist.
|
||||
|
||||
You can check its DNS record on any DNS checker websites, for example https://mxtoolbox.com/SuperTool.aspx
|
||||
|
||||
Please let us know if you have any question.
|
||||
|
||||
Best,
|
||||
SimpleLogin team.
|
|
@ -4,7 +4,7 @@
|
|||
{{ render_text("Hi " + name) }}
|
||||
|
||||
{% call text() %}
|
||||
We have recorded an attempt to send an email from your alias <b>{{ alias.email }}</b> using <b>{{ sender }}</b>>
|
||||
We have recorded an attempt to send an email from your alias <b>{{ alias.email }}</b> using <b>{{ sender }}</b>.
|
||||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
{{ render_text("We have recorded an attempt to send an email from your email <b>" + sender + "</b> to <b>" + reply_email + "</b>.") }}
|
||||
|
||||
{{ render_text(reply_email + 'is a special email address that only receives emails from its authorized user.') }}
|
||||
{{ render_text(reply_email + ' is a special email address that only receives emails from its authorized user.') }}
|
||||
|
||||
{{ render_text('This user has been also informed of this incident.') }}
|
||||
|
||||
|
|
|
@ -35,3 +35,40 @@ def test_wrong_api_key(flask_client):
|
|||
assert r.status_code == 401
|
||||
|
||||
assert r.json == {"error": "Wrong api key"}
|
||||
|
||||
|
||||
def test_create_api_key(flask_client):
|
||||
# create user, user is activated
|
||||
User.create(email="a@b.c", password="password", name="Test User", activated=True)
|
||||
db.session.commit()
|
||||
|
||||
# login user
|
||||
flask_client.post(
|
||||
url_for("auth.login"),
|
||||
data={"email": "a@b.c", "password": "password"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
# create api key
|
||||
r = flask_client.post(url_for("api.create_api_key"), json={"device": "Test device"})
|
||||
|
||||
assert r.status_code == 201
|
||||
assert r.json["api_key"]
|
||||
|
||||
|
||||
def test_logout(flask_client):
|
||||
# create user, user is activated
|
||||
User.create(email="a@b.c", password="password", name="Test User", activated=True)
|
||||
db.session.commit()
|
||||
|
||||
# login user
|
||||
flask_client.post(
|
||||
url_for("auth.login"),
|
||||
data={"email": "a@b.c", "password": "password"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
# logout
|
||||
r = flask_client.get(url_for("auth.logout"), follow_redirects=True,)
|
||||
|
||||
assert r.status_code == 200
|
||||
|
|
|
@ -11,6 +11,7 @@ from app.email_utils import (
|
|||
parseaddr_unicode,
|
||||
send_email_with_rate_control,
|
||||
copy,
|
||||
get_spam_from_header,
|
||||
)
|
||||
from app.extensions import db
|
||||
from app.models import User, CustomDomain
|
||||
|
@ -134,3 +135,28 @@ def test_copy():
|
|||
msg2 = copy(msg)
|
||||
|
||||
assert msg.as_bytes() == msg2.as_bytes()
|
||||
|
||||
|
||||
def test_get_spam_from_header():
|
||||
is_spam, _ = get_spam_from_header(
|
||||
"""No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
|
||||
DKIM_VALID_AU,RCVD_IN_DNSWL_BLOCKED,RCVD_IN_MSPIKE_H2,SPF_PASS,
|
||||
URIBL_BLOCKED autolearn=unavailable autolearn_force=no version=3.4.2"""
|
||||
)
|
||||
assert not is_spam
|
||||
|
||||
is_spam, _ = get_spam_from_header(
|
||||
"""Yes, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
|
||||
DKIM_VALID_AU,RCVD_IN_DNSWL_BLOCKED,RCVD_IN_MSPIKE_H2,SPF_PASS,
|
||||
URIBL_BLOCKED autolearn=unavailable autolearn_force=no version=3.4.2"""
|
||||
)
|
||||
assert is_spam
|
||||
|
||||
# the case where max_score is less than the default used by SpamAssassin
|
||||
is_spam, _ = get_spam_from_header(
|
||||
"""No, score=6 required=10.0 tests=DKIM_SIGNED,DKIM_VALID,
|
||||
DKIM_VALID_AU,RCVD_IN_DNSWL_BLOCKED,RCVD_IN_MSPIKE_H2,SPF_PASS,
|
||||
URIBL_BLOCKED autolearn=unavailable autolearn_force=no version=3.4.2""",
|
||||
max_score=5,
|
||||
)
|
||||
assert is_spam
|
||||
|
|
Loading…
Reference in New Issue