Merge remote-tracking branch 'origin/master' into new/admin-audit-trail
* origin/master: (35 commits) reduce nb of commit show "more" only when a notification has a title. Show either title or message. Use bold font when a notification isn't read create a notification when an alias is disabled mark a notification as read when user arrives on the notification page Use plausible outbound link tracking add more log fix discover page fix fix "local variable 'alias_id' referenced before assignment" make sure to close session in monitoring use Date instead of date for header value lessen alias automatic disable check refactor return the block reason in should_disable() add adhoc upgrade on admin add extend subscription for 1 month to admin disable edition on admin comment out some admin pages fix migration fix duplicated stats ...
This commit is contained in:
commit
d0860cd54d
|
@ -5,7 +5,7 @@ from flask import redirect, url_for, request, flash
|
||||||
from flask_admin import expose, AdminIndexView
|
from flask_admin import expose, AdminIndexView
|
||||||
from flask_admin.actions import action
|
from flask_admin.actions import action
|
||||||
from flask_admin.contrib import sqla
|
from flask_admin.contrib import sqla
|
||||||
from flask_login import current_user, login_user
|
from flask_login import current_user
|
||||||
|
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
|
@ -91,7 +91,7 @@ class UserAdmin(SLModelView):
|
||||||
"fido_uuid",
|
"fido_uuid",
|
||||||
"profile_picture",
|
"profile_picture",
|
||||||
]
|
]
|
||||||
can_edit = True
|
can_edit = False
|
||||||
|
|
||||||
def scaffold_list_columns(self):
|
def scaffold_list_columns(self):
|
||||||
ret = super().scaffold_list_columns()
|
ret = super().scaffold_list_columns()
|
||||||
|
@ -138,6 +138,14 @@ class UserAdmin(SLModelView):
|
||||||
def action_monero_upgrade(self, ids):
|
def action_monero_upgrade(self, ids):
|
||||||
manual_upgrade("Crypto", ids, is_giveaway=False)
|
manual_upgrade("Crypto", ids, is_giveaway=False)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
"adhoc_upgrade",
|
||||||
|
"Adhoc upgrade - for exceptional case",
|
||||||
|
"Are you sure you want to crypto-upgrade selected users?",
|
||||||
|
)
|
||||||
|
def action_adhoc_upgrade(self, ids):
|
||||||
|
manual_upgrade("Adhoc", ids, is_giveaway=False)
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
"extend_trial_1w",
|
"extend_trial_1w",
|
||||||
"Extend trial for 1 week more",
|
"Extend trial for 1 week more",
|
||||||
|
@ -178,22 +186,21 @@ class UserAdmin(SLModelView):
|
||||||
|
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
@action(
|
# @action(
|
||||||
"login_as",
|
# "login_as",
|
||||||
"Login as this user",
|
# "Login as this user",
|
||||||
"Login as this user?",
|
# "Login as this user?",
|
||||||
)
|
# )
|
||||||
def login_as(self, ids):
|
# def login_as(self, ids):
|
||||||
if len(ids) != 1:
|
# if len(ids) != 1:
|
||||||
flash("only 1 user can be selected", "error")
|
# flash("only 1 user can be selected", "error")
|
||||||
return
|
# return
|
||||||
|
#
|
||||||
for user in User.filter(User.id.in_(ids)):
|
# for user in User.filter(User.id.in_(ids)):
|
||||||
AdminAuditLog.logged_as_user(current_user.id, user.id)
|
# AdminAuditLog.logged_as_user(current_user.id, user.id)
|
||||||
Session.commit()
|
# login_user(user)
|
||||||
login_user(user)
|
# flash(f"Login as user {user}", "success")
|
||||||
flash(f"Login as user {user}", "success")
|
# return redirect("/")
|
||||||
return redirect("/")
|
|
||||||
|
|
||||||
|
|
||||||
def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
|
def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
|
||||||
|
@ -258,18 +265,18 @@ class MailboxAdmin(SLModelView):
|
||||||
column_filters = ["id", "user.email", "email"]
|
column_filters = ["id", "user.email", "email"]
|
||||||
|
|
||||||
|
|
||||||
class LifetimeCouponAdmin(SLModelView):
|
# class LifetimeCouponAdmin(SLModelView):
|
||||||
can_edit = True
|
# can_edit = True
|
||||||
can_create = True
|
# can_create = True
|
||||||
|
|
||||||
|
|
||||||
class CouponAdmin(SLModelView):
|
class CouponAdmin(SLModelView):
|
||||||
can_edit = True
|
can_edit = False
|
||||||
can_create = True
|
can_create = True
|
||||||
|
|
||||||
|
|
||||||
class ManualSubscriptionAdmin(SLModelView):
|
class ManualSubscriptionAdmin(SLModelView):
|
||||||
can_edit = True
|
can_edit = False
|
||||||
column_searchable_list = ["id", "user.email"]
|
column_searchable_list = ["id", "user.email"]
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
|
@ -280,15 +287,27 @@ class ManualSubscriptionAdmin(SLModelView):
|
||||||
def extend_1y(self, ids):
|
def extend_1y(self, ids):
|
||||||
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)):
|
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)):
|
||||||
ms.end_at = ms.end_at.shift(years=1)
|
ms.end_at = ms.end_at.shift(years=1)
|
||||||
flash(f"Extend subscription for {ms.user}", "success")
|
flash(f"Extend subscription for 1 year for {ms.user}", "success")
|
||||||
|
|
||||||
|
Session.commit()
|
||||||
|
|
||||||
|
@action(
|
||||||
|
"extend_1m",
|
||||||
|
"Extend for 1 month",
|
||||||
|
"Extend 1 month more?",
|
||||||
|
)
|
||||||
|
def extend_1m(self, ids):
|
||||||
|
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)):
|
||||||
|
ms.end_at = ms.end_at.shift(months=1)
|
||||||
|
flash(f"Extend subscription for 1 month for {ms.user}", "success")
|
||||||
|
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
|
|
||||||
class ClientAdmin(SLModelView):
|
# class ClientAdmin(SLModelView):
|
||||||
column_searchable_list = ["name", "description", "user.email"]
|
# column_searchable_list = ["name", "description", "user.email"]
|
||||||
column_exclude_list = ["oauth_client_secret", "home_url"]
|
# column_exclude_list = ["oauth_client_secret", "home_url"]
|
||||||
can_edit = True
|
# can_edit = True
|
||||||
|
|
||||||
|
|
||||||
class CustomDomainAdmin(SLModelView):
|
class CustomDomainAdmin(SLModelView):
|
||||||
|
@ -308,12 +327,12 @@ class ReferralAdmin(SLModelView):
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
class PayoutAdmin(SLModelView):
|
#class PayoutAdmin(SLModelView):
|
||||||
column_searchable_list = ["id", "user.email"]
|
# column_searchable_list = ["id", "user.email"]
|
||||||
column_filters = ["id", "user.email"]
|
# column_filters = ["id", "user.email"]
|
||||||
can_edit = True
|
# can_edit = True
|
||||||
can_create = True
|
# can_create = True
|
||||||
can_delete = True
|
# can_delete = True
|
||||||
|
|
||||||
|
|
||||||
class AdminAuditLogAdmin(SLModelView):
|
class AdminAuditLogAdmin(SLModelView):
|
||||||
|
|
|
@ -204,7 +204,7 @@ def get_alias_infos_with_pagination_v3(
|
||||||
q = list(q.limit(page_limit).offset(page_id * page_size))
|
q = list(q.limit(page_limit).offset(page_id * page_size))
|
||||||
|
|
||||||
ret = []
|
ret = []
|
||||||
for alias, contact, email_log, custom_domain, nb_reply, nb_blocked, nb_forward in q:
|
for alias, contact, email_log, nb_reply, nb_blocked, nb_forward in q:
|
||||||
ret.append(
|
ret.append(
|
||||||
AliasInfo(
|
AliasInfo(
|
||||||
alias=alias,
|
alias=alias,
|
||||||
|
@ -215,7 +215,7 @@ def get_alias_infos_with_pagination_v3(
|
||||||
nb_reply=nb_reply,
|
nb_reply=nb_reply,
|
||||||
latest_email_log=email_log,
|
latest_email_log=email_log,
|
||||||
latest_contact=contact,
|
latest_contact=contact,
|
||||||
custom_domain=custom_domain,
|
custom_domain=alias.custom_domain,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -318,7 +318,7 @@ def get_alias_info_v3(user: User, alias_id: int) -> AliasInfo:
|
||||||
q = construct_alias_query(user)
|
q = construct_alias_query(user)
|
||||||
q = q.filter(Alias.id == alias_id)
|
q = q.filter(Alias.id == alias_id)
|
||||||
|
|
||||||
for alias, contact, email_log, custom_domain, nb_reply, nb_blocked, nb_forward in q:
|
for alias, contact, email_log, nb_reply, nb_blocked, nb_forward in q:
|
||||||
return AliasInfo(
|
return AliasInfo(
|
||||||
alias=alias,
|
alias=alias,
|
||||||
mailbox=alias.mailbox,
|
mailbox=alias.mailbox,
|
||||||
|
@ -328,7 +328,7 @@ def get_alias_info_v3(user: User, alias_id: int) -> AliasInfo:
|
||||||
nb_reply=nb_reply,
|
nb_reply=nb_reply,
|
||||||
latest_email_log=email_log,
|
latest_email_log=email_log,
|
||||||
latest_contact=contact,
|
latest_contact=contact,
|
||||||
custom_domain=custom_domain,
|
custom_domain=alias.custom_domain,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -379,14 +379,13 @@ def construct_alias_query(user: User):
|
||||||
Alias,
|
Alias,
|
||||||
Contact,
|
Contact,
|
||||||
EmailLog,
|
EmailLog,
|
||||||
CustomDomain,
|
|
||||||
alias_activity_subquery.c.nb_reply,
|
alias_activity_subquery.c.nb_reply,
|
||||||
alias_activity_subquery.c.nb_blocked,
|
alias_activity_subquery.c.nb_blocked,
|
||||||
alias_activity_subquery.c.nb_forward,
|
alias_activity_subquery.c.nb_forward,
|
||||||
)
|
)
|
||||||
.options(joinedload(Alias.hibp_breaches))
|
.options(joinedload(Alias.hibp_breaches))
|
||||||
|
.options(joinedload(Alias.custom_domain))
|
||||||
.join(Contact, Alias.id == Contact.alias_id, isouter=True)
|
.join(Contact, Alias.id == Contact.alias_id, isouter=True)
|
||||||
.join(CustomDomain, Alias.custom_domain_id == CustomDomain.id, isouter=True)
|
|
||||||
.join(EmailLog, Contact.id == EmailLog.contact_id, isouter=True)
|
.join(EmailLog, Contact.id == EmailLog.contact_id, isouter=True)
|
||||||
.filter(Alias.id == alias_activity_subquery.c.id)
|
.filter(Alias.id == alias_activity_subquery.c.id)
|
||||||
.filter(Alias.id == alias_contact_subquery.c.id)
|
.filter(Alias.id == alias_contact_subquery.c.id)
|
||||||
|
|
|
@ -431,3 +431,11 @@ def get_allowed_redirect_domains() -> List[str]:
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_REDIRECT_DOMAINS = get_allowed_redirect_domains()
|
ALLOWED_REDIRECT_DOMAINS = get_allowed_redirect_domains()
|
||||||
|
|
||||||
|
|
||||||
|
def setup_nameservers():
|
||||||
|
nameservers = os.environ.get("NAMESERVERS", "1.1.1.1")
|
||||||
|
return nameservers.split(",")
|
||||||
|
|
||||||
|
|
||||||
|
NAMESERVERS = setup_nameservers()
|
||||||
|
|
|
@ -23,6 +23,10 @@ def notification_route(notification_id):
|
||||||
)
|
)
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
|
|
||||||
|
if not notification.read:
|
||||||
|
notification.read = True
|
||||||
|
Session.commit()
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
notification_title = notification.title or notification.message[:20]
|
notification_title = notification.title or notification.message[:20]
|
||||||
Notification.delete(notification_id)
|
Notification.delete(notification_id)
|
||||||
|
|
|
@ -315,6 +315,15 @@ def setting():
|
||||||
return redirect(url_for("dashboard.setting"))
|
return redirect(url_for("dashboard.setting"))
|
||||||
Session.commit()
|
Session.commit()
|
||||||
flash("Your preference has been updated", "success")
|
flash("Your preference has been updated", "success")
|
||||||
|
elif request.form.get("form-name") == "sender-header":
|
||||||
|
choose = request.form.get("enable")
|
||||||
|
if choose == "on":
|
||||||
|
current_user.include_header_email_header = True
|
||||||
|
else:
|
||||||
|
current_user.include_header_email_header = False
|
||||||
|
Session.commit()
|
||||||
|
flash("Your preference has been updated", "success")
|
||||||
|
return redirect(url_for("dashboard.setting"))
|
||||||
elif request.form.get("form-name") == "export-data":
|
elif request.form.get("form-name") == "export-data":
|
||||||
return redirect(url_for("api.export_data"))
|
return redirect(url_for("api.export_data"))
|
||||||
elif request.form.get("form-name") == "export-alias":
|
elif request.form.get("form-name") == "export-alias":
|
||||||
|
|
|
@ -8,5 +8,5 @@ from app.models import Client
|
||||||
@discover_bp.route("/", methods=["GET", "POST"])
|
@discover_bp.route("/", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
clients = Client.filter_by(published=True).all()
|
clients = Client.filter_by(approved=True).all()
|
||||||
return render_template("discover/index.html", clients=clients)
|
return render_template("discover/index.html", clients=clients)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
from app import config
|
||||||
from typing import Optional, List, Tuple
|
from typing import Optional, List, Tuple
|
||||||
|
|
||||||
import dns.resolver
|
import dns.resolver
|
||||||
|
@ -5,16 +6,14 @@ import dns.resolver
|
||||||
|
|
||||||
def _get_dns_resolver():
|
def _get_dns_resolver():
|
||||||
my_resolver = dns.resolver.Resolver()
|
my_resolver = dns.resolver.Resolver()
|
||||||
|
my_resolver.nameservers = config.NAMESERVERS
|
||||||
# 1.1.1.1 is CloudFlare's public DNS server
|
|
||||||
my_resolver.nameservers = ["1.1.1.1"]
|
|
||||||
|
|
||||||
return my_resolver
|
return my_resolver
|
||||||
|
|
||||||
|
|
||||||
def get_ns(hostname) -> [str]:
|
def get_ns(hostname) -> [str]:
|
||||||
try:
|
try:
|
||||||
answers = _get_dns_resolver().resolve(hostname, "NS")
|
answers = _get_dns_resolver().resolve(hostname, "NS", search=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
return [a.to_text() for a in answers]
|
return [a.to_text() for a in answers]
|
||||||
|
@ -23,7 +22,7 @@ def get_ns(hostname) -> [str]:
|
||||||
def get_cname_record(hostname) -> Optional[str]:
|
def get_cname_record(hostname) -> Optional[str]:
|
||||||
"""Return the CNAME record if exists for a domain, WITHOUT the trailing period at the end"""
|
"""Return the CNAME record if exists for a domain, WITHOUT the trailing period at the end"""
|
||||||
try:
|
try:
|
||||||
answers = _get_dns_resolver().resolve(hostname, "CNAME")
|
answers = _get_dns_resolver().resolve(hostname, "CNAME", search=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -39,7 +38,7 @@ def get_mx_domains(hostname) -> [(int, str)]:
|
||||||
domain name ends with a "." at the end.
|
domain name ends with a "." at the end.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
answers = _get_dns_resolver().resolve(hostname, "MX")
|
answers = _get_dns_resolver().resolve(hostname, "MX", search=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@ -60,7 +59,7 @@ _include_spf = "include:"
|
||||||
def get_spf_domain(hostname) -> [str]:
|
def get_spf_domain(hostname) -> [str]:
|
||||||
"""return all domains listed in *include:*"""
|
"""return all domains listed in *include:*"""
|
||||||
try:
|
try:
|
||||||
answers = _get_dns_resolver().resolve(hostname, "TXT")
|
answers = _get_dns_resolver().resolve(hostname, "TXT", search=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@ -82,7 +81,7 @@ def get_spf_domain(hostname) -> [str]:
|
||||||
def get_txt_record(hostname) -> [str]:
|
def get_txt_record(hostname) -> [str]:
|
||||||
"""return all domains listed in *include:*"""
|
"""return all domains listed in *include:*"""
|
||||||
try:
|
try:
|
||||||
answers = _get_dns_resolver().resolve(hostname, "TXT")
|
answers = _get_dns_resolver().resolve(hostname, "TXT", search=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@ -112,10 +111,10 @@ def is_mx_equivalent(
|
||||||
ref_mx_domains, key=lambda priority_domain: priority_domain[0]
|
ref_mx_domains, key=lambda priority_domain: priority_domain[0]
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(mx_domains) != len(ref_mx_domains):
|
if len(mx_domains) < len(ref_mx_domains):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for i in range(0, len(mx_domains)):
|
for i in range(0, len(ref_mx_domains)):
|
||||||
if mx_domains[i][1] != ref_mx_domains[i][1]:
|
if mx_domains[i][1] != ref_mx_domains[i][1]:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
MESSAGE_ID = "Message-ID"
|
MESSAGE_ID = "Message-ID"
|
||||||
IN_REPLY_TO = "In-Reply-To"
|
IN_REPLY_TO = "In-Reply-To"
|
||||||
REFERENCES = "References"
|
REFERENCES = "References"
|
||||||
DATE = "date"
|
DATE = "Date"
|
||||||
SUBJECT = "Subject"
|
SUBJECT = "Subject"
|
||||||
FROM = "From"
|
FROM = "From"
|
||||||
TO = "To"
|
TO = "To"
|
||||||
|
|
|
@ -1118,15 +1118,17 @@ def normalize_reply_email(reply_email: str) -> str:
|
||||||
return "".join(ret)
|
return "".join(ret)
|
||||||
|
|
||||||
|
|
||||||
def should_disable(alias: Alias) -> bool:
|
def should_disable(alias: Alias) -> (bool, str):
|
||||||
"""Disable an alias if it has too many bounces recently"""
|
"""
|
||||||
|
Return whether an alias should be disabled and if yes, the reason why
|
||||||
|
"""
|
||||||
# Bypass the bounce rule
|
# Bypass the bounce rule
|
||||||
if alias.cannot_be_disabled:
|
if alias.cannot_be_disabled:
|
||||||
LOG.w("%s cannot be disabled", alias)
|
LOG.w("%s cannot be disabled", alias)
|
||||||
return False
|
return False, ""
|
||||||
|
|
||||||
if not ALIAS_AUTOMATIC_DISABLE:
|
if not ALIAS_AUTOMATIC_DISABLE:
|
||||||
return False
|
return False, ""
|
||||||
|
|
||||||
yesterday = arrow.now().shift(days=-1)
|
yesterday = arrow.now().shift(days=-1)
|
||||||
nb_bounced_last_24h = (
|
nb_bounced_last_24h = (
|
||||||
|
@ -1141,12 +1143,11 @@ def should_disable(alias: Alias) -> bool:
|
||||||
)
|
)
|
||||||
# if more than 12 bounces in 24h -> disable alias
|
# if more than 12 bounces in 24h -> disable alias
|
||||||
if nb_bounced_last_24h > 12:
|
if nb_bounced_last_24h > 12:
|
||||||
LOG.d("more than 12 bounces in the last 24h, disable alias %s", alias)
|
return True, "+12 bounces in the last 24h"
|
||||||
return True
|
|
||||||
|
|
||||||
# if more than 5 bounces but has bounces last week -> disable alias
|
# if more than 5 bounces but has +10 bounces last week -> disable alias
|
||||||
elif nb_bounced_last_24h > 5:
|
elif nb_bounced_last_24h > 5:
|
||||||
one_week_ago = arrow.now().shift(days=-8)
|
one_week_ago = arrow.now().shift(days=-7)
|
||||||
nb_bounced_7d_1d = (
|
nb_bounced_7d_1d = (
|
||||||
Session.query(EmailLog)
|
Session.query(EmailLog)
|
||||||
.filter(
|
.filter(
|
||||||
|
@ -1158,16 +1159,14 @@ def should_disable(alias: Alias) -> bool:
|
||||||
.filter(EmailLog.alias_id == alias.id)
|
.filter(EmailLog.alias_id == alias.id)
|
||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
if nb_bounced_7d_1d > 1:
|
if nb_bounced_7d_1d > 10:
|
||||||
LOG.d(
|
return (
|
||||||
"more than 5 bounces in the last 24h and more than 1 bounces in the last 7 days, "
|
True,
|
||||||
"disable alias %s",
|
"+5 bounces in the last 24h and +10 bounces in the last 7 days",
|
||||||
alias,
|
|
||||||
)
|
)
|
||||||
return True
|
|
||||||
else:
|
else:
|
||||||
# alias level
|
# alias level
|
||||||
# if bounces at least 9 days in the last 10 days -> disable alias
|
# if bounces happen for at least 9 days in the last 10 days -> disable alias
|
||||||
query = (
|
query = (
|
||||||
Session.query(
|
Session.query(
|
||||||
func.date(EmailLog.created_at).label("date"),
|
func.date(EmailLog.created_at).label("date"),
|
||||||
|
@ -1183,11 +1182,7 @@ def should_disable(alias: Alias) -> bool:
|
||||||
)
|
)
|
||||||
|
|
||||||
if query.count() >= 9:
|
if query.count() >= 9:
|
||||||
LOG.d(
|
return True, "Bounces every day for at least 9 days in the last 10 days"
|
||||||
"Bounces every day for at least 9 days in the last 10 days, disable alias %s",
|
|
||||||
alias,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
# account level
|
# account level
|
||||||
query = (
|
query = (
|
||||||
|
@ -1206,16 +1201,13 @@ def should_disable(alias: Alias) -> bool:
|
||||||
|
|
||||||
# if an account has more than 10 bounces every day for at least 4 days in the last 10 days, disable alias
|
# if an account has more than 10 bounces every day for at least 4 days in the last 10 days, disable alias
|
||||||
date_bounces: List[Tuple[arrow.Arrow, int]] = list(query)
|
date_bounces: List[Tuple[arrow.Arrow, int]] = list(query)
|
||||||
if len(date_bounces) > 4:
|
more_than_10_bounces = [
|
||||||
if all([v > 10 for _, v in date_bounces]):
|
(d, nb_bounce) for d, nb_bounce in date_bounces if nb_bounce > 10
|
||||||
LOG.d(
|
]
|
||||||
"+10 bounces for +4 days in the last 10 days on %s, disable alias %s",
|
if len(more_than_10_bounces) > 4:
|
||||||
alias.user,
|
return True, "+10 bounces for +4 days in the last 10 days"
|
||||||
alias,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
return False, ""
|
||||||
|
|
||||||
|
|
||||||
def parse_id_from_bounce(email_address: str) -> int:
|
def parse_id_from_bounce(email_address: str) -> int:
|
||||||
|
|
|
@ -450,6 +450,11 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||||
server_default=BlockBehaviourEnum.return_2xx.name,
|
server_default=BlockBehaviourEnum.return_2xx.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# to keep existing behavior, the server default is TRUE whereas for new user, the default value is FALSE
|
||||||
|
include_header_email_header = sa.Column(
|
||||||
|
sa.Boolean, default=False, nullable=False, server_default="1"
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def directory_quota(self):
|
def directory_quota(self):
|
||||||
return min(
|
return min(
|
||||||
|
@ -1160,7 +1165,7 @@ class Alias(Base, ModelMixin):
|
||||||
enabled = sa.Column(sa.Boolean(), default=True, nullable=False)
|
enabled = sa.Column(sa.Boolean(), default=True, nullable=False)
|
||||||
|
|
||||||
custom_domain_id = sa.Column(
|
custom_domain_id = sa.Column(
|
||||||
sa.ForeignKey("custom_domain.id", ondelete="cascade"), nullable=True
|
sa.ForeignKey("custom_domain.id", ondelete="cascade"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
custom_domain = orm.relationship("CustomDomain", foreign_keys=[custom_domain_id])
|
custom_domain = orm.relationship("CustomDomain", foreign_keys=[custom_domain_id])
|
||||||
|
@ -1172,7 +1177,7 @@ class Alias(Base, ModelMixin):
|
||||||
|
|
||||||
# to know whether an alias belongs to a directory
|
# to know whether an alias belongs to a directory
|
||||||
directory_id = sa.Column(
|
directory_id = sa.Column(
|
||||||
sa.ForeignKey("directory.id", ondelete="cascade"), nullable=True
|
sa.ForeignKey("directory.id", ondelete="cascade"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
note = sa.Column(sa.Text, default=None, nullable=True)
|
note = sa.Column(sa.Text, default=None, nullable=True)
|
||||||
|
|
56
cron.py
56
cron.py
|
@ -465,7 +465,7 @@ def alias_creation_report() -> List[Tuple[str, int]]:
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
def growth_stats():
|
def stats():
|
||||||
"""send admin stats everyday"""
|
"""send admin stats everyday"""
|
||||||
if not ADMIN_EMAIL:
|
if not ADMIN_EMAIL:
|
||||||
LOG.w("ADMIN_EMAIL not set, nothing to do")
|
LOG.w("ADMIN_EMAIL not set, nothing to do")
|
||||||
|
@ -480,7 +480,7 @@ def growth_stats():
|
||||||
|
|
||||||
today = arrow.now().format()
|
today = arrow.now().format()
|
||||||
|
|
||||||
report = f"""
|
growth_stats = f"""
|
||||||
Growth Stats for {today}
|
Growth Stats for {today}
|
||||||
|
|
||||||
nb_user: {stats_today.nb_user} - {increase_percent(stats_yesterday.nb_user, stats_today.nb_user)}
|
nb_user: {stats_today.nb_user} - {increase_percent(stats_yesterday.nb_user, stats_today.nb_user)}
|
||||||
|
@ -507,32 +507,16 @@ nb_referred_user: {stats_today.nb_referred_user} - {increase_percent(stats_yeste
|
||||||
nb_referred_user_upgrade: {stats_today.nb_referred_user_paid} - {increase_percent(stats_yesterday.nb_referred_user_paid, stats_today.nb_referred_user_paid)}
|
nb_referred_user_upgrade: {stats_today.nb_referred_user_paid} - {increase_percent(stats_yesterday.nb_referred_user_paid, stats_today.nb_referred_user_paid)}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
LOG.d("report email: %s", report)
|
LOG.d("growth_stats email: %s", growth_stats)
|
||||||
|
|
||||||
send_email(
|
send_email(
|
||||||
ADMIN_EMAIL,
|
ADMIN_EMAIL,
|
||||||
subject=f"SimpleLogin Growth Stats for {today}",
|
subject=f"SimpleLogin Growth Stats for {today}",
|
||||||
plaintext=report,
|
plaintext=growth_stats,
|
||||||
retries=3,
|
retries=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
monitoring_report = f"""
|
||||||
def daily_monitoring_report():
|
|
||||||
"""send monitoring stats of the previous day"""
|
|
||||||
if not MONITORING_EMAIL:
|
|
||||||
LOG.w("MONITORING_EMAIL not set, nothing to do")
|
|
||||||
return
|
|
||||||
|
|
||||||
stats_today = compute_metric2()
|
|
||||||
stats_yesterday = (
|
|
||||||
Metric2.filter(Metric2.date < stats_today.date)
|
|
||||||
.order_by(Metric2.date.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
today = arrow.now().format()
|
|
||||||
|
|
||||||
report = f"""
|
|
||||||
Monitoring Stats for {today}
|
Monitoring Stats for {today}
|
||||||
|
|
||||||
nb_alias: {stats_today.nb_alias} - {increase_percent(stats_yesterday.nb_alias, stats_today.nb_alias)}
|
nb_alias: {stats_today.nb_alias} - {increase_percent(stats_yesterday.nb_alias, stats_today.nb_alias)}
|
||||||
|
@ -545,32 +529,32 @@ nb_total_bounced_last_24h: {stats_today.nb_total_bounced_last_24h} - {increase_p
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
report += "\n====================================\n"
|
monitoring_report += "\n====================================\n"
|
||||||
report += f"""
|
monitoring_report += f"""
|
||||||
# Account bounce report:
|
# Account bounce report:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for email, bounces in bounce_report():
|
for email, bounces in bounce_report():
|
||||||
report += f"{email}: {bounces}\n"
|
monitoring_report += f"{email}: {bounces}\n"
|
||||||
|
|
||||||
report += f"""\n
|
monitoring_report += f"""\n
|
||||||
# Alias creation report:
|
# Alias creation report:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for email, nb_alias, date in alias_creation_report():
|
for email, nb_alias, date in alias_creation_report():
|
||||||
report += f"{email}, {date}: {nb_alias}\n"
|
monitoring_report += f"{email}, {date}: {nb_alias}\n"
|
||||||
|
|
||||||
report += f"""\n
|
monitoring_report += f"""\n
|
||||||
# Full bounce detail report:
|
# Full bounce detail report:
|
||||||
"""
|
"""
|
||||||
report += all_bounce_report()
|
monitoring_report += all_bounce_report()
|
||||||
|
|
||||||
LOG.d("report email: %s", report)
|
LOG.d("monitoring_report email: %s", monitoring_report)
|
||||||
|
|
||||||
send_email(
|
send_email(
|
||||||
MONITORING_EMAIL,
|
MONITORING_EMAIL,
|
||||||
subject=f"SimpleLogin Monitoring Report for {today}",
|
subject=f"SimpleLogin Monitoring Report for {today}",
|
||||||
plaintext=report,
|
plaintext=monitoring_report,
|
||||||
retries=3,
|
retries=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1040,8 +1024,7 @@ if __name__ == "__main__":
|
||||||
help="Choose a cron job to run",
|
help="Choose a cron job to run",
|
||||||
type=str,
|
type=str,
|
||||||
choices=[
|
choices=[
|
||||||
"growth_stats",
|
"stats",
|
||||||
"daily_monitoring_report",
|
|
||||||
"notify_trial_end",
|
"notify_trial_end",
|
||||||
"notify_manual_subscription_end",
|
"notify_manual_subscription_end",
|
||||||
"notify_premium_end",
|
"notify_premium_end",
|
||||||
|
@ -1057,12 +1040,9 @@ if __name__ == "__main__":
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
# wrap in an app context to benefit from app setup like database cleanup, sentry integration, etc
|
# wrap in an app context to benefit from app setup like database cleanup, sentry integration, etc
|
||||||
with create_light_app().app_context():
|
with create_light_app().app_context():
|
||||||
if args.job == "growth_stats":
|
if args.job == "stats":
|
||||||
LOG.d("Compute growth Stats")
|
LOG.d("Compute growth and daily monitoring stats")
|
||||||
growth_stats()
|
stats()
|
||||||
if args.job == "daily_monitoring_report":
|
|
||||||
LOG.d("Send out daily monitoring stats")
|
|
||||||
daily_monitoring_report()
|
|
||||||
elif args.job == "notify_trial_end":
|
elif args.job == "notify_trial_end":
|
||||||
LOG.d("Notify users with trial ending soon")
|
LOG.d("Notify users with trial ending soon")
|
||||||
notify_trial_end()
|
notify_trial_end()
|
||||||
|
|
|
@ -1,12 +1,6 @@
|
||||||
jobs:
|
jobs:
|
||||||
- name: SimpleLogin growth stats
|
- name: SimpleLogin growth stats
|
||||||
command: python /code/cron.py -j growth_stats
|
command: python /code/cron.py -j stats
|
||||||
shell: /bin/bash
|
|
||||||
schedule: "0 1 * * *"
|
|
||||||
captureStderr: true
|
|
||||||
|
|
||||||
- name: SimpleLogin monitoring stats
|
|
||||||
command: python /code/cron.py -j daily_monitoring_report
|
|
||||||
shell: /bin/bash
|
shell: /bin/bash
|
||||||
schedule: "0 0 * * *"
|
schedule: "0 0 * * *"
|
||||||
captureStderr: true
|
captureStderr: true
|
||||||
|
|
|
@ -801,7 +801,8 @@ def forward_email_to_mailbox(
|
||||||
add_or_replace_header(msg, headers.SL_DIRECTION, "Forward")
|
add_or_replace_header(msg, headers.SL_DIRECTION, "Forward")
|
||||||
|
|
||||||
msg[headers.SL_EMAIL_LOG_ID] = str(email_log.id)
|
msg[headers.SL_EMAIL_LOG_ID] = str(email_log.id)
|
||||||
msg[headers.SL_ENVELOPE_FROM] = envelope.mail_from
|
if user.include_header_email_header:
|
||||||
|
msg[headers.SL_ENVELOPE_FROM] = envelope.mail_from
|
||||||
# when an alias isn't in the To: header, there's no way for users to know what alias has received the email
|
# when an alias isn't in the To: header, there's no way for users to know what alias has received the email
|
||||||
msg[headers.SL_ENVELOPE_TO] = alias.email
|
msg[headers.SL_ENVELOPE_TO] = alias.email
|
||||||
|
|
||||||
|
@ -1394,8 +1395,44 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
|
||||||
|
|
||||||
refused_email_url = f"{URL}/dashboard/refused_email?highlight_id={email_log.id}"
|
refused_email_url = f"{URL}/dashboard/refused_email?highlight_id={email_log.id}"
|
||||||
|
|
||||||
# inform user of this bounce
|
alias_will_be_disabled, reason = should_disable(alias)
|
||||||
if not should_disable(alias):
|
if alias_will_be_disabled:
|
||||||
|
LOG.w(
|
||||||
|
f"Disable alias {alias} because {reason}. {alias.mailboxes} {alias.user}. Last contact {contact}"
|
||||||
|
)
|
||||||
|
alias.enabled = False
|
||||||
|
|
||||||
|
Notification.create(
|
||||||
|
user_id=user.id,
|
||||||
|
title=f"{alias.email} has been disabled due to multiple bounces",
|
||||||
|
message=Notification.render(
|
||||||
|
"notification/alias-disable.html", alias=alias, mailbox=mailbox
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Session.commit()
|
||||||
|
|
||||||
|
send_email_with_rate_control(
|
||||||
|
user,
|
||||||
|
ALERT_BOUNCE_EMAIL,
|
||||||
|
user.email,
|
||||||
|
f"Alias {alias.email} has been disabled due to multiple bounces",
|
||||||
|
render(
|
||||||
|
"transactional/bounce/automatic-disable-alias.txt",
|
||||||
|
alias=alias,
|
||||||
|
refused_email_url=refused_email_url,
|
||||||
|
mailbox_email=mailbox.email,
|
||||||
|
),
|
||||||
|
render(
|
||||||
|
"transactional/bounce/automatic-disable-alias.html",
|
||||||
|
alias=alias,
|
||||||
|
refused_email_url=refused_email_url,
|
||||||
|
mailbox_email=mailbox.email,
|
||||||
|
),
|
||||||
|
max_nb_alert=10,
|
||||||
|
ignore_smtp_error=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
LOG.d(
|
LOG.d(
|
||||||
"Inform user %s about a bounce from contact %s to alias %s",
|
"Inform user %s about a bounce from contact %s to alias %s",
|
||||||
user,
|
user,
|
||||||
|
@ -1445,31 +1482,6 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
|
||||||
# smtp error can happen if user mailbox is unreachable, that might explain the bounce
|
# smtp error can happen if user mailbox is unreachable, that might explain the bounce
|
||||||
ignore_smtp_error=True,
|
ignore_smtp_error=True,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
LOG.w("Disable alias %s %s. Last contact %s", alias, user, contact)
|
|
||||||
alias.enabled = False
|
|
||||||
Session.commit()
|
|
||||||
|
|
||||||
send_email_with_rate_control(
|
|
||||||
user,
|
|
||||||
ALERT_BOUNCE_EMAIL,
|
|
||||||
user.email,
|
|
||||||
f"Alias {alias.email} has been disabled due to multiple bounces",
|
|
||||||
render(
|
|
||||||
"transactional/bounce/automatic-disable-alias.txt",
|
|
||||||
alias=alias,
|
|
||||||
refused_email_url=refused_email_url,
|
|
||||||
mailbox_email=mailbox.email,
|
|
||||||
),
|
|
||||||
render(
|
|
||||||
"transactional/bounce/automatic-disable-alias.html",
|
|
||||||
alias=alias,
|
|
||||||
refused_email_url=refused_email_url,
|
|
||||||
mailbox_email=mailbox.email,
|
|
||||||
),
|
|
||||||
max_nb_alert=10,
|
|
||||||
ignore_smtp_error=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_hotmail_complaint(msg: Message) -> bool:
|
def handle_hotmail_complaint(msg: Message) -> bool:
|
||||||
|
@ -1899,18 +1911,23 @@ def handle_unsubscribe(envelope: Envelope, msg: Message) -> str:
|
||||||
alias_id = int(subject)
|
alias_id = int(subject)
|
||||||
alias = Alias.get(alias_id)
|
alias = Alias.get(alias_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.w("Cannot parse alias from subject %s", msg[headers.SUBJECT])
|
LOG.w("Wrong format subject %s", msg[headers.SUBJECT])
|
||||||
return status.E507
|
return status.E507
|
||||||
|
|
||||||
if not alias:
|
if not alias:
|
||||||
LOG.w("No such alias %s", alias_id)
|
LOG.w("Cannot get alias from subject %s", subject)
|
||||||
return status.E508
|
return status.E508
|
||||||
|
|
||||||
mail_from = envelope.mail_from
|
mail_from = envelope.mail_from
|
||||||
# Only alias's owning mailbox can send the unsubscribe request
|
# Only alias's owning mailbox can send the unsubscribe request
|
||||||
mailbox = get_mailbox_from_mail_from(mail_from, alias)
|
mailbox = get_mailbox_from_mail_from(mail_from, alias)
|
||||||
if not mailbox:
|
if not mailbox:
|
||||||
LOG.d("%s cannot disable alias %s", envelope.mail_from, alias)
|
LOG.d(
|
||||||
|
"%s cannot disable alias %s. Alias authorized addresses:%s",
|
||||||
|
envelope.mail_from,
|
||||||
|
alias,
|
||||||
|
alias.authorized_addresses,
|
||||||
|
)
|
||||||
return status.E509
|
return status.E509
|
||||||
|
|
||||||
user = alias.user
|
user = alias.user
|
||||||
|
|
|
@ -175,3 +175,7 @@ DISABLE_ONBOARDING=true
|
||||||
|
|
||||||
# domains that can be present in the &next= section when using absolute urls
|
# domains that can be present in the &next= section when using absolute urls
|
||||||
ALLOWED_REDIRECT_DOMAINS=[]
|
ALLOWED_REDIRECT_DOMAINS=[]
|
||||||
|
|
||||||
|
# DNS nameservers to be used by the app
|
||||||
|
# Multiple nameservers can be specified, separated by ','
|
||||||
|
NAMESERVERS="1.1.1.1"
|
||||||
|
|
29
migrations/versions/2022_022512_4729b7096d12_.py
Normal file
29
migrations/versions/2022_022512_4729b7096d12_.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 4729b7096d12
|
||||||
|
Revises: 9282e982bc05
|
||||||
|
Create Date: 2022-02-25 12:11:10.991810
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '4729b7096d12'
|
||||||
|
down_revision = '5047fcbd57c7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('users', sa.Column('include_header_email_header', sa.Boolean(), server_default='1', nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('users', 'include_header_email_header')
|
||||||
|
# ### end Alembic commands ###
|
31
migrations/versions/2022_022617_5047fcbd57c7_.py
Normal file
31
migrations/versions/2022_022617_5047fcbd57c7_.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 5047fcbd57c7
|
||||||
|
Revises: 9282e982bc05
|
||||||
|
Create Date: 2022-02-26 17:51:03.379676
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '5047fcbd57c7'
|
||||||
|
down_revision = '9282e982bc05'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_index(op.f('ix_alias_custom_domain_id'), 'alias', ['custom_domain_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_alias_directory_id'), 'alias', ['directory_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_alias_directory_id'), table_name='alias')
|
||||||
|
op.drop_index(op.f('ix_alias_custom_domain_id'), table_name='alias')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -50,6 +50,7 @@ if __name__ == "__main__":
|
||||||
while True:
|
while True:
|
||||||
log_postfix_metrics()
|
log_postfix_metrics()
|
||||||
log_nb_db_connection()
|
log_nb_db_connection()
|
||||||
|
Session.close()
|
||||||
|
|
||||||
# 1 min
|
# 1 min
|
||||||
sleep(60)
|
sleep(60)
|
||||||
|
|
12
server.py
12
server.py
|
@ -33,11 +33,7 @@ from app.admin_model import (
|
||||||
EmailLogAdmin,
|
EmailLogAdmin,
|
||||||
AliasAdmin,
|
AliasAdmin,
|
||||||
MailboxAdmin,
|
MailboxAdmin,
|
||||||
LifetimeCouponAdmin,
|
|
||||||
ManualSubscriptionAdmin,
|
ManualSubscriptionAdmin,
|
||||||
ClientAdmin,
|
|
||||||
ReferralAdmin,
|
|
||||||
PayoutAdmin,
|
|
||||||
CouponAdmin,
|
CouponAdmin,
|
||||||
CustomDomainAdmin,
|
CustomDomainAdmin,
|
||||||
AdminAuditLogAdmin,
|
AdminAuditLogAdmin,
|
||||||
|
@ -82,20 +78,16 @@ from app.fake_data import fake_data
|
||||||
from app.jose_utils import get_jwk_key
|
from app.jose_utils import get_jwk_key
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Client,
|
|
||||||
User,
|
User,
|
||||||
Alias,
|
Alias,
|
||||||
Subscription,
|
Subscription,
|
||||||
PlanEnum,
|
PlanEnum,
|
||||||
CustomDomain,
|
CustomDomain,
|
||||||
LifetimeCoupon,
|
|
||||||
Mailbox,
|
Mailbox,
|
||||||
Referral,
|
|
||||||
CoinbaseSubscription,
|
CoinbaseSubscription,
|
||||||
EmailLog,
|
EmailLog,
|
||||||
Contact,
|
Contact,
|
||||||
ManualSubscription,
|
ManualSubscription,
|
||||||
Payout,
|
|
||||||
Coupon,
|
Coupon,
|
||||||
AdminAuditLog,
|
AdminAuditLog,
|
||||||
)
|
)
|
||||||
|
@ -695,13 +687,9 @@ def init_admin(app):
|
||||||
admin.add_view(AliasAdmin(Alias, Session))
|
admin.add_view(AliasAdmin(Alias, Session))
|
||||||
admin.add_view(MailboxAdmin(Mailbox, Session))
|
admin.add_view(MailboxAdmin(Mailbox, Session))
|
||||||
admin.add_view(EmailLogAdmin(EmailLog, Session))
|
admin.add_view(EmailLogAdmin(EmailLog, Session))
|
||||||
admin.add_view(LifetimeCouponAdmin(LifetimeCoupon, Session))
|
|
||||||
admin.add_view(CouponAdmin(Coupon, Session))
|
admin.add_view(CouponAdmin(Coupon, Session))
|
||||||
admin.add_view(ManualSubscriptionAdmin(ManualSubscription, Session))
|
admin.add_view(ManualSubscriptionAdmin(ManualSubscription, Session))
|
||||||
admin.add_view(ClientAdmin(Client, Session))
|
|
||||||
admin.add_view(CustomDomainAdmin(CustomDomain, Session))
|
admin.add_view(CustomDomainAdmin(CustomDomain, Session))
|
||||||
admin.add_view(ReferralAdmin(Referral, Session))
|
|
||||||
admin.add_view(PayoutAdmin(Payout, Session))
|
|
||||||
admin.add_view(AdminAuditLogAdmin(AdminAuditLog, Session))
|
admin.add_view(AdminAuditLogAdmin(AdminAuditLog, Session))
|
||||||
|
|
||||||
|
|
||||||
|
|
28
static/js/utils/drag-drop-into-text.js
Normal file
28
static/js/utils/drag-drop-into-text.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
const MAX_BYTES = 10240; // 10KiB
|
||||||
|
|
||||||
|
function enableDragDropForPGPKeys(inputID) {
|
||||||
|
function drop(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
let files = event.dataTransfer.files;
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
let file = files[i];
|
||||||
|
if(file.type !== 'text/plain'){
|
||||||
|
toastr.warning(`File ${file.name} is not a public key file`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let reader = new FileReader();
|
||||||
|
reader.onloadend = onFileLoaded;
|
||||||
|
reader.readAsBinaryString(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileLoaded(event) {
|
||||||
|
const initialData = event.currentTarget.result.substr(0, MAX_BYTES);
|
||||||
|
$(inputID).val(initialData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropArea = $(inputID).get(0);
|
||||||
|
dropArea.addEventListener("drop", drop, false);
|
||||||
|
}
|
|
@ -87,7 +87,7 @@
|
||||||
|
|
||||||
{% if PLAUSIBLE_HOST and PLAUSIBLE_DOMAIN %}
|
{% if PLAUSIBLE_HOST and PLAUSIBLE_DOMAIN %}
|
||||||
<!-- Plausible Analytics library -->
|
<!-- Plausible Analytics library -->
|
||||||
<script async defer data-domain=”{{ PLAUSIBLE_DOMAIN }}” src=”{{ PLAUSIBLE_HOST }}/js/plausible.js”></script>
|
<script async defer data-domain=”{{ PLAUSIBLE_DOMAIN }}” src=”{{ PLAUSIBLE_HOST }}/js/plausible.outbound-links.js”></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='darkmode.css') }}?v={{ VERSION }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='darkmode.css') }}?v={{ VERSION }}">
|
||||||
|
|
|
@ -44,13 +44,16 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-2">You can drag and drop the pgp key file into the text area</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">PGP Public Key</label>
|
<label class="form-label">PGP Public Key</label>
|
||||||
|
|
||||||
<textarea name="pgp"
|
<textarea name="pgp"
|
||||||
{% if not current_user.is_premium() %} disabled {% endif %}
|
{% if not current_user.is_premium() %} disabled {% endif %}
|
||||||
class="form-control" rows=10
|
class="form-control" rows=10 id="pgp-public-key"
|
||||||
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ contact.pgp_public_key or "" }}</textarea>
|
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ contact.pgp_public_key or "" }}</textarea>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-primary" name="action"
|
<button class="btn btn-primary" name="action"
|
||||||
|
@ -70,4 +73,9 @@
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script src="/static/js/utils/drag-drop-into-text.js"></script>
|
||||||
|
<script>
|
||||||
|
enableDragDropForPGPKeys('#pgp-public-key');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
|
@ -284,7 +284,7 @@
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if alias_info.custom_domain and not alias_info.custom_domain.verified %}
|
{% if alias.custom_domain and not alias.custom_domain.verified %}
|
||||||
<span class="fa fa-warning text-warning" data-toggle="tooltip"
|
<span class="fa fa-warning text-warning" data-toggle="tooltip"
|
||||||
title="Alias can't receive emails as its domain doesn't have MX records set up."></span>
|
title="Alias can't receive emails as its domain doesn't have MX records set up."></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -117,14 +117,16 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-2">You can drag and drop the pgp key file into the text area</div>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">PGP Public Key</label>
|
<label class="form-label">PGP Public Key</label>
|
||||||
|
|
||||||
<textarea name="pgp"
|
<textarea name="pgp"
|
||||||
{% if not current_user.is_premium() %} disabled {% endif %}
|
{% if not current_user.is_premium() %} disabled {% endif %}
|
||||||
class="form-control" rows=10
|
class="form-control" rows=10 id="pgp-public-key"
|
||||||
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea>
|
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" name="form-name" value="pgp">
|
<input type="hidden" name="form-name" value="pgp">
|
||||||
|
@ -263,10 +265,12 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
|
<script src="/static/js/utils/drag-drop-into-text.js"></script>
|
||||||
<script>
|
<script>
|
||||||
$(".custom-switch-input").change(function (e) {
|
$(".custom-switch-input").change(function (e) {
|
||||||
$(this).closest("form").submit();
|
$(this).closest("form").submit();
|
||||||
});
|
});
|
||||||
|
enableDragDropForPGPKeys('#pgp-public-key');
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
@ -520,6 +520,30 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="sender-header">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title">Include original sender in email headers
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
SimpleLogin forwards emails to your mailbox from the <b>reverse-alias</b> and not from the <b>original</b> sender address. <br>
|
||||||
|
If this option is enabled, the original sender addresses is stored in the email header <b>X-SimpleLogin-Envelope-From</b>.
|
||||||
|
You can choose to display this header in your email client. <br>
|
||||||
|
As email headers aren't encrypted, your mailbox service can know the sender address via this header.
|
||||||
|
</div>
|
||||||
|
<form method="post" action="#sender-header">
|
||||||
|
<input type="hidden" name="form-name" value="sender-header">
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="include-sender-header" name="enable"
|
||||||
|
{% if current_user.include_header_email_header %} checked {% endif %} class="form-check-input">
|
||||||
|
<label for="include-sender-header">Include sender address in email headers</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-outline-primary">Update</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">Alias Import</div>
|
<div class="card-title">Alias Import</div>
|
||||||
|
|
|
@ -19,9 +19,5 @@
|
||||||
The email is automatically deleted in 7 days.
|
The email is automatically deleted in 7 days.
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
{% call text() %}
|
|
||||||
If you have any question, please reply to this email.
|
|
||||||
{% endcall %}
|
|
||||||
|
|
||||||
{{ render_text('Thanks, <br />SimpleLogin Team.') }}
|
{{ render_text('Thanks, <br />SimpleLogin Team.') }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -38,15 +38,13 @@
|
||||||
|
|
||||||
<div class="dropdown-item d-flex" v-for="notification in notifications">
|
<div class="dropdown-item d-flex" v-for="notification in notifications">
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div v-html="notification.title" class="font-weight-bold"
|
|
||||||
|
<div v-html="notification.title || notification.message"
|
||||||
|
:class="!notification.read && 'font-weight-bold'"
|
||||||
style="width: 40em; word-wrap:break-word; white-space: normal; overflow: hidden;"></div>
|
style="width: 40em; word-wrap:break-word; white-space: normal; overflow: hidden;"></div>
|
||||||
|
|
||||||
|
|
||||||
<div v-html="notification.message"
|
<div v-if="notification.title">
|
||||||
style="width: 40em; word-wrap:break-word; white-space: normal; overflow: hidden; max-height: 100px; text-overflow: ellipsis;">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<a :href="'/dashboard/notification/' + notification.id">More</a>
|
<a :href="'/dashboard/notification/' + notification.id">More</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
10
templates/notification/alias-disable.html
Normal file
10
templates/notification/alias-disable.html
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<div>
|
||||||
|
There are several emails sent to your alias <b>{{ alias.email }}</b> that have been bounced by your
|
||||||
|
mailbox <b>{{ mailbox.email }}</b>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
As security measure, we have disabled the alias <b>{{ alias.email }}</b>.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
|
@ -38,3 +38,11 @@ def test_is_mx_equivalent():
|
||||||
assert is_mx_equivalent(
|
assert is_mx_equivalent(
|
||||||
[(5, "domain1"), (10, "domain2")], [(10, "domain1"), (20, "domain2")]
|
[(5, "domain1"), (10, "domain2")], [(10, "domain1"), (20, "domain2")]
|
||||||
)
|
)
|
||||||
|
assert is_mx_equivalent(
|
||||||
|
[(5, "domain1"), (10, "domain2"), (20, "domain3")],
|
||||||
|
[(10, "domain1"), (20, "domain2")],
|
||||||
|
)
|
||||||
|
assert not is_mx_equivalent(
|
||||||
|
[(5, "domain1"), (10, "domain2")],
|
||||||
|
[(10, "domain1"), (20, "domain2"), (20, "domain3")],
|
||||||
|
)
|
||||||
|
|
|
@ -610,7 +610,7 @@ def test_should_disable(flask_client):
|
||||||
alias = Alias.create_new_random(user)
|
alias = Alias.create_new_random(user)
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
assert not should_disable(alias)
|
assert not should_disable(alias)[0]
|
||||||
|
|
||||||
# create a lot of bounce on this alias
|
# create a lot of bounce on this alias
|
||||||
contact = Contact.create(
|
contact = Contact.create(
|
||||||
|
@ -629,12 +629,12 @@ def test_should_disable(flask_client):
|
||||||
bounced=True,
|
bounced=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert should_disable(alias)
|
assert should_disable(alias)[0]
|
||||||
|
|
||||||
# should not affect another alias
|
# should not affect another alias
|
||||||
alias2 = Alias.create_new_random(user)
|
alias2 = Alias.create_new_random(user)
|
||||||
Session.commit()
|
Session.commit()
|
||||||
assert not should_disable(alias2)
|
assert not should_disable(alias2)[0]
|
||||||
|
|
||||||
|
|
||||||
def test_should_disable_bounces_every_day(flask_client):
|
def test_should_disable_bounces_every_day(flask_client):
|
||||||
|
@ -643,7 +643,7 @@ def test_should_disable_bounces_every_day(flask_client):
|
||||||
alias = Alias.create_new_random(user)
|
alias = Alias.create_new_random(user)
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
assert not should_disable(alias)
|
assert not should_disable(alias)[0]
|
||||||
|
|
||||||
# create a lot of bounce on this alias
|
# create a lot of bounce on this alias
|
||||||
contact = Contact.create(
|
contact = Contact.create(
|
||||||
|
@ -663,7 +663,7 @@ def test_should_disable_bounces_every_day(flask_client):
|
||||||
created_at=arrow.now().shift(days=-i),
|
created_at=arrow.now().shift(days=-i),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert should_disable(alias)
|
assert should_disable(alias)[0]
|
||||||
|
|
||||||
|
|
||||||
def test_should_disable_bounces_account(flask_client):
|
def test_should_disable_bounces_account(flask_client):
|
||||||
|
@ -682,8 +682,8 @@ def test_should_disable_bounces_account(flask_client):
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
for day in range(6):
|
for day in range(5):
|
||||||
for _ in range(10):
|
for _ in range(11):
|
||||||
EmailLog.create(
|
EmailLog.create(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
contact_id=contact.id,
|
contact_id=contact.id,
|
||||||
|
@ -694,7 +694,7 @@ def test_should_disable_bounces_account(flask_client):
|
||||||
)
|
)
|
||||||
|
|
||||||
alias2 = Alias.create_new_random(user)
|
alias2 = Alias.create_new_random(user)
|
||||||
assert should_disable(alias2)
|
assert should_disable(alias2)[0]
|
||||||
|
|
||||||
|
|
||||||
def test_should_disable_bounce_consecutive_days(flask_client):
|
def test_should_disable_bounce_consecutive_days(flask_client):
|
||||||
|
@ -719,10 +719,10 @@ def test_should_disable_bounce_consecutive_days(flask_client):
|
||||||
commit=True,
|
commit=True,
|
||||||
bounced=True,
|
bounced=True,
|
||||||
)
|
)
|
||||||
assert not should_disable(alias)
|
assert not should_disable(alias)[0]
|
||||||
|
|
||||||
# create 2 bounces in the last 7 days: alias should be disabled
|
# create +10 bounces in the last 7 days: alias should be disabled
|
||||||
for _ in range(2):
|
for _ in range(11):
|
||||||
EmailLog.create(
|
EmailLog.create(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
contact_id=contact.id,
|
contact_id=contact.id,
|
||||||
|
@ -731,7 +731,7 @@ def test_should_disable_bounce_consecutive_days(flask_client):
|
||||||
bounced=True,
|
bounced=True,
|
||||||
created_at=arrow.now().shift(days=-3),
|
created_at=arrow.now().shift(days=-3),
|
||||||
)
|
)
|
||||||
assert should_disable(alias)
|
assert should_disable(alias)[0]
|
||||||
|
|
||||||
|
|
||||||
def test_parse_id_from_bounce():
|
def test_parse_id_from_bounce():
|
||||||
|
|
Loading…
Reference in a new issue