Merge branch 'master' into fix-doc

This commit is contained in:
Son Nguyen Kim 2020-07-23 14:15:24 +02:00 committed by GitHub
commit c78e3a6ee2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 890 additions and 181 deletions

1
.github/FUNDING.yml vendored
View File

@ -1,2 +1,3 @@
patreon: simplelogin
open_collective: simple_login
custom: ["https://www.paypal.me/RealSimpleLogin"]

2
.gitignore vendored
View File

@ -11,4 +11,4 @@ db.sqlite-journal
static/upload
adhoc_*
adhoc.py
env/
venv/

View File

@ -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

View File

@ -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()

View File

@ -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):
"""

View File

@ -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():
"""

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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():
"""

View File

@ -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):

View File

@ -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():
"""

View File

@ -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):
"""

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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",

View File

@ -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")

View File

@ -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):

View File

@ -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")

View File

@ -23,4 +23,5 @@ from .views import (
referral,
recovery_code,
contact_detail,
setup_done,
)

View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -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,

View File

@ -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"))

View File

@ -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,
)

View File

@ -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")

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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):

View File

@ -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)

View File

@ -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:

View File

@ -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}"
)

View File

@ -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
View File

@ -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")

View File

@ -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
):

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 %}

View File

@ -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.

View File

@ -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() %}

View File

@ -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.') }}

View File

@ -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

View File

@ -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