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.actions import action
|
||||
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.db import Session
|
||||
|
@ -91,7 +91,7 @@ class UserAdmin(SLModelView):
|
|||
"fido_uuid",
|
||||
"profile_picture",
|
||||
]
|
||||
can_edit = True
|
||||
can_edit = False
|
||||
|
||||
def scaffold_list_columns(self):
|
||||
ret = super().scaffold_list_columns()
|
||||
|
@ -138,6 +138,14 @@ class UserAdmin(SLModelView):
|
|||
def action_monero_upgrade(self, ids):
|
||||
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(
|
||||
"extend_trial_1w",
|
||||
"Extend trial for 1 week more",
|
||||
|
@ -178,22 +186,21 @@ class UserAdmin(SLModelView):
|
|||
|
||||
Session.commit()
|
||||
|
||||
@action(
|
||||
"login_as",
|
||||
"Login as this user",
|
||||
"Login as this user?",
|
||||
)
|
||||
def login_as(self, ids):
|
||||
if len(ids) != 1:
|
||||
flash("only 1 user can be selected", "error")
|
||||
return
|
||||
|
||||
for user in User.filter(User.id.in_(ids)):
|
||||
AdminAuditLog.logged_as_user(current_user.id, user.id)
|
||||
Session.commit()
|
||||
login_user(user)
|
||||
flash(f"Login as user {user}", "success")
|
||||
return redirect("/")
|
||||
# @action(
|
||||
# "login_as",
|
||||
# "Login as this user",
|
||||
# "Login as this user?",
|
||||
# )
|
||||
# def login_as(self, ids):
|
||||
# if len(ids) != 1:
|
||||
# flash("only 1 user can be selected", "error")
|
||||
# return
|
||||
#
|
||||
# for user in User.filter(User.id.in_(ids)):
|
||||
# AdminAuditLog.logged_as_user(current_user.id, user.id)
|
||||
# login_user(user)
|
||||
# flash(f"Login as user {user}", "success")
|
||||
# return redirect("/")
|
||||
|
||||
|
||||
def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
|
||||
|
@ -258,18 +265,18 @@ class MailboxAdmin(SLModelView):
|
|||
column_filters = ["id", "user.email", "email"]
|
||||
|
||||
|
||||
class LifetimeCouponAdmin(SLModelView):
|
||||
can_edit = True
|
||||
can_create = True
|
||||
# class LifetimeCouponAdmin(SLModelView):
|
||||
# can_edit = True
|
||||
# can_create = True
|
||||
|
||||
|
||||
class CouponAdmin(SLModelView):
|
||||
can_edit = True
|
||||
can_edit = False
|
||||
can_create = True
|
||||
|
||||
|
||||
class ManualSubscriptionAdmin(SLModelView):
|
||||
can_edit = True
|
||||
can_edit = False
|
||||
column_searchable_list = ["id", "user.email"]
|
||||
|
||||
@action(
|
||||
|
@ -280,15 +287,27 @@ class ManualSubscriptionAdmin(SLModelView):
|
|||
def extend_1y(self, ids):
|
||||
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)):
|
||||
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()
|
||||
|
||||
|
||||
class ClientAdmin(SLModelView):
|
||||
column_searchable_list = ["name", "description", "user.email"]
|
||||
column_exclude_list = ["oauth_client_secret", "home_url"]
|
||||
can_edit = True
|
||||
# class ClientAdmin(SLModelView):
|
||||
# column_searchable_list = ["name", "description", "user.email"]
|
||||
# column_exclude_list = ["oauth_client_secret", "home_url"]
|
||||
# can_edit = True
|
||||
|
||||
|
||||
class CustomDomainAdmin(SLModelView):
|
||||
|
@ -308,12 +327,12 @@ class ReferralAdmin(SLModelView):
|
|||
return ret
|
||||
|
||||
|
||||
class PayoutAdmin(SLModelView):
|
||||
column_searchable_list = ["id", "user.email"]
|
||||
column_filters = ["id", "user.email"]
|
||||
can_edit = True
|
||||
can_create = True
|
||||
can_delete = True
|
||||
#class PayoutAdmin(SLModelView):
|
||||
# column_searchable_list = ["id", "user.email"]
|
||||
# column_filters = ["id", "user.email"]
|
||||
# can_edit = True
|
||||
# can_create = True
|
||||
# can_delete = True
|
||||
|
||||
|
||||
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))
|
||||
|
||||
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(
|
||||
AliasInfo(
|
||||
alias=alias,
|
||||
|
@ -215,7 +215,7 @@ def get_alias_infos_with_pagination_v3(
|
|||
nb_reply=nb_reply,
|
||||
latest_email_log=email_log,
|
||||
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 = 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(
|
||||
alias=alias,
|
||||
mailbox=alias.mailbox,
|
||||
|
@ -328,7 +328,7 @@ def get_alias_info_v3(user: User, alias_id: int) -> AliasInfo:
|
|||
nb_reply=nb_reply,
|
||||
latest_email_log=email_log,
|
||||
latest_contact=contact,
|
||||
custom_domain=custom_domain,
|
||||
custom_domain=alias.custom_domain,
|
||||
)
|
||||
|
||||
|
||||
|
@ -379,14 +379,13 @@ def construct_alias_query(user: User):
|
|||
Alias,
|
||||
Contact,
|
||||
EmailLog,
|
||||
CustomDomain,
|
||||
alias_activity_subquery.c.nb_reply,
|
||||
alias_activity_subquery.c.nb_blocked,
|
||||
alias_activity_subquery.c.nb_forward,
|
||||
)
|
||||
.options(joinedload(Alias.hibp_breaches))
|
||||
.options(joinedload(Alias.custom_domain))
|
||||
.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)
|
||||
.filter(Alias.id == alias_activity_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()
|
||||
|
||||
|
||||
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"))
|
||||
|
||||
if not notification.read:
|
||||
notification.read = True
|
||||
Session.commit()
|
||||
|
||||
if request.method == "POST":
|
||||
notification_title = notification.title or notification.message[:20]
|
||||
Notification.delete(notification_id)
|
||||
|
|
|
@ -315,6 +315,15 @@ def setting():
|
|||
return redirect(url_for("dashboard.setting"))
|
||||
Session.commit()
|
||||
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":
|
||||
return redirect(url_for("api.export_data"))
|
||||
elif request.form.get("form-name") == "export-alias":
|
||||
|
|
|
@ -8,5 +8,5 @@ from app.models import Client
|
|||
@discover_bp.route("/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def index():
|
||||
clients = Client.filter_by(published=True).all()
|
||||
clients = Client.filter_by(approved=True).all()
|
||||
return render_template("discover/index.html", clients=clients)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from app import config
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
import dns.resolver
|
||||
|
@ -5,16 +6,14 @@ import dns.resolver
|
|||
|
||||
def _get_dns_resolver():
|
||||
my_resolver = dns.resolver.Resolver()
|
||||
|
||||
# 1.1.1.1 is CloudFlare's public DNS server
|
||||
my_resolver.nameservers = ["1.1.1.1"]
|
||||
my_resolver.nameservers = config.NAMESERVERS
|
||||
|
||||
return my_resolver
|
||||
|
||||
|
||||
def get_ns(hostname) -> [str]:
|
||||
try:
|
||||
answers = _get_dns_resolver().resolve(hostname, "NS")
|
||||
answers = _get_dns_resolver().resolve(hostname, "NS", search=True)
|
||||
except Exception:
|
||||
return []
|
||||
return [a.to_text() for a in answers]
|
||||
|
@ -23,7 +22,7 @@ def get_ns(hostname) -> [str]:
|
|||
def get_cname_record(hostname) -> Optional[str]:
|
||||
"""Return the CNAME record if exists for a domain, WITHOUT the trailing period at the end"""
|
||||
try:
|
||||
answers = _get_dns_resolver().resolve(hostname, "CNAME")
|
||||
answers = _get_dns_resolver().resolve(hostname, "CNAME", search=True)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
@ -39,7 +38,7 @@ def get_mx_domains(hostname) -> [(int, str)]:
|
|||
domain name ends with a "." at the end.
|
||||
"""
|
||||
try:
|
||||
answers = _get_dns_resolver().resolve(hostname, "MX")
|
||||
answers = _get_dns_resolver().resolve(hostname, "MX", search=True)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
@ -60,7 +59,7 @@ _include_spf = "include:"
|
|||
def get_spf_domain(hostname) -> [str]:
|
||||
"""return all domains listed in *include:*"""
|
||||
try:
|
||||
answers = _get_dns_resolver().resolve(hostname, "TXT")
|
||||
answers = _get_dns_resolver().resolve(hostname, "TXT", search=True)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
@ -82,7 +81,7 @@ def get_spf_domain(hostname) -> [str]:
|
|||
def get_txt_record(hostname) -> [str]:
|
||||
"""return all domains listed in *include:*"""
|
||||
try:
|
||||
answers = _get_dns_resolver().resolve(hostname, "TXT")
|
||||
answers = _get_dns_resolver().resolve(hostname, "TXT", search=True)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
@ -112,10 +111,10 @@ def is_mx_equivalent(
|
|||
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
|
||||
|
||||
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]:
|
||||
return False
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
MESSAGE_ID = "Message-ID"
|
||||
IN_REPLY_TO = "In-Reply-To"
|
||||
REFERENCES = "References"
|
||||
DATE = "date"
|
||||
DATE = "Date"
|
||||
SUBJECT = "Subject"
|
||||
FROM = "From"
|
||||
TO = "To"
|
||||
|
|
|
@ -1118,15 +1118,17 @@ def normalize_reply_email(reply_email: str) -> str:
|
|||
return "".join(ret)
|
||||
|
||||
|
||||
def should_disable(alias: Alias) -> bool:
|
||||
"""Disable an alias if it has too many bounces recently"""
|
||||
def should_disable(alias: Alias) -> (bool, str):
|
||||
"""
|
||||
Return whether an alias should be disabled and if yes, the reason why
|
||||
"""
|
||||
# Bypass the bounce rule
|
||||
if alias.cannot_be_disabled:
|
||||
LOG.w("%s cannot be disabled", alias)
|
||||
return False
|
||||
return False, ""
|
||||
|
||||
if not ALIAS_AUTOMATIC_DISABLE:
|
||||
return False
|
||||
return False, ""
|
||||
|
||||
yesterday = arrow.now().shift(days=-1)
|
||||
nb_bounced_last_24h = (
|
||||
|
@ -1141,12 +1143,11 @@ def should_disable(alias: Alias) -> bool:
|
|||
)
|
||||
# if more than 12 bounces in 24h -> disable alias
|
||||
if nb_bounced_last_24h > 12:
|
||||
LOG.d("more than 12 bounces in the last 24h, disable alias %s", alias)
|
||||
return True
|
||||
return True, "+12 bounces in the last 24h"
|
||||
|
||||
# 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:
|
||||
one_week_ago = arrow.now().shift(days=-8)
|
||||
one_week_ago = arrow.now().shift(days=-7)
|
||||
nb_bounced_7d_1d = (
|
||||
Session.query(EmailLog)
|
||||
.filter(
|
||||
|
@ -1158,16 +1159,14 @@ def should_disable(alias: Alias) -> bool:
|
|||
.filter(EmailLog.alias_id == alias.id)
|
||||
.count()
|
||||
)
|
||||
if nb_bounced_7d_1d > 1:
|
||||
LOG.d(
|
||||
"more than 5 bounces in the last 24h and more than 1 bounces in the last 7 days, "
|
||||
"disable alias %s",
|
||||
alias,
|
||||
if nb_bounced_7d_1d > 10:
|
||||
return (
|
||||
True,
|
||||
"+5 bounces in the last 24h and +10 bounces in the last 7 days",
|
||||
)
|
||||
return True
|
||||
else:
|
||||
# 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 = (
|
||||
Session.query(
|
||||
func.date(EmailLog.created_at).label("date"),
|
||||
|
@ -1183,11 +1182,7 @@ def should_disable(alias: Alias) -> bool:
|
|||
)
|
||||
|
||||
if query.count() >= 9:
|
||||
LOG.d(
|
||||
"Bounces every day for at least 9 days in the last 10 days, disable alias %s",
|
||||
alias,
|
||||
)
|
||||
return True
|
||||
return True, "Bounces every day for at least 9 days in the last 10 days"
|
||||
|
||||
# account level
|
||||
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
|
||||
date_bounces: List[Tuple[arrow.Arrow, int]] = list(query)
|
||||
if len(date_bounces) > 4:
|
||||
if all([v > 10 for _, v in date_bounces]):
|
||||
LOG.d(
|
||||
"+10 bounces for +4 days in the last 10 days on %s, disable alias %s",
|
||||
alias.user,
|
||||
alias,
|
||||
)
|
||||
return True
|
||||
more_than_10_bounces = [
|
||||
(d, nb_bounce) for d, nb_bounce in date_bounces if nb_bounce > 10
|
||||
]
|
||||
if len(more_than_10_bounces) > 4:
|
||||
return True, "+10 bounces for +4 days in the last 10 days"
|
||||
|
||||
return False
|
||||
return False, ""
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# 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
|
||||
def directory_quota(self):
|
||||
return min(
|
||||
|
@ -1160,7 +1165,7 @@ class Alias(Base, ModelMixin):
|
|||
enabled = sa.Column(sa.Boolean(), default=True, nullable=False)
|
||||
|
||||
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])
|
||||
|
@ -1172,7 +1177,7 @@ class Alias(Base, ModelMixin):
|
|||
|
||||
# to know whether an alias belongs to a directory
|
||||
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)
|
||||
|
|
56
cron.py
56
cron.py
|
@ -465,7 +465,7 @@ def alias_creation_report() -> List[Tuple[str, int]]:
|
|||
return res
|
||||
|
||||
|
||||
def growth_stats():
|
||||
def stats():
|
||||
"""send admin stats everyday"""
|
||||
if not ADMIN_EMAIL:
|
||||
LOG.w("ADMIN_EMAIL not set, nothing to do")
|
||||
|
@ -480,7 +480,7 @@ def growth_stats():
|
|||
|
||||
today = arrow.now().format()
|
||||
|
||||
report = f"""
|
||||
growth_stats = f"""
|
||||
Growth Stats for {today}
|
||||
|
||||
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)}
|
||||
"""
|
||||
|
||||
LOG.d("report email: %s", report)
|
||||
LOG.d("growth_stats email: %s", growth_stats)
|
||||
|
||||
send_email(
|
||||
ADMIN_EMAIL,
|
||||
subject=f"SimpleLogin Growth Stats for {today}",
|
||||
plaintext=report,
|
||||
plaintext=growth_stats,
|
||||
retries=3,
|
||||
)
|
||||
|
||||
|
||||
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_report = f"""
|
||||
Monitoring Stats for {today}
|
||||
|
||||
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"
|
||||
report += f"""
|
||||
monitoring_report += "\n====================================\n"
|
||||
monitoring_report += f"""
|
||||
# Account 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:
|
||||
"""
|
||||
|
||||
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:
|
||||
"""
|
||||
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(
|
||||
MONITORING_EMAIL,
|
||||
subject=f"SimpleLogin Monitoring Report for {today}",
|
||||
plaintext=report,
|
||||
plaintext=monitoring_report,
|
||||
retries=3,
|
||||
)
|
||||
|
||||
|
@ -1040,8 +1024,7 @@ if __name__ == "__main__":
|
|||
help="Choose a cron job to run",
|
||||
type=str,
|
||||
choices=[
|
||||
"growth_stats",
|
||||
"daily_monitoring_report",
|
||||
"stats",
|
||||
"notify_trial_end",
|
||||
"notify_manual_subscription_end",
|
||||
"notify_premium_end",
|
||||
|
@ -1057,12 +1040,9 @@ if __name__ == "__main__":
|
|||
args = parser.parse_args()
|
||||
# wrap in an app context to benefit from app setup like database cleanup, sentry integration, etc
|
||||
with create_light_app().app_context():
|
||||
if args.job == "growth_stats":
|
||||
LOG.d("Compute growth Stats")
|
||||
growth_stats()
|
||||
if args.job == "daily_monitoring_report":
|
||||
LOG.d("Send out daily monitoring stats")
|
||||
daily_monitoring_report()
|
||||
if args.job == "stats":
|
||||
LOG.d("Compute growth and daily monitoring stats")
|
||||
stats()
|
||||
elif args.job == "notify_trial_end":
|
||||
LOG.d("Notify users with trial ending soon")
|
||||
notify_trial_end()
|
||||
|
|
|
@ -1,12 +1,6 @@
|
|||
jobs:
|
||||
- name: SimpleLogin growth stats
|
||||
command: python /code/cron.py -j growth_stats
|
||||
shell: /bin/bash
|
||||
schedule: "0 1 * * *"
|
||||
captureStderr: true
|
||||
|
||||
- name: SimpleLogin monitoring stats
|
||||
command: python /code/cron.py -j daily_monitoring_report
|
||||
command: python /code/cron.py -j stats
|
||||
shell: /bin/bash
|
||||
schedule: "0 0 * * *"
|
||||
captureStderr: true
|
||||
|
|
|
@ -801,7 +801,8 @@ def forward_email_to_mailbox(
|
|||
add_or_replace_header(msg, headers.SL_DIRECTION, "Forward")
|
||||
|
||||
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
|
||||
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}"
|
||||
|
||||
# inform user of this bounce
|
||||
if not should_disable(alias):
|
||||
alias_will_be_disabled, reason = 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(
|
||||
"Inform user %s about a bounce from contact %s to alias %s",
|
||||
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
|
||||
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:
|
||||
|
@ -1899,18 +1911,23 @@ def handle_unsubscribe(envelope: Envelope, msg: Message) -> str:
|
|||
alias_id = int(subject)
|
||||
alias = Alias.get(alias_id)
|
||||
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
|
||||
|
||||
if not alias:
|
||||
LOG.w("No such alias %s", alias_id)
|
||||
LOG.w("Cannot get alias from subject %s", subject)
|
||||
return status.E508
|
||||
|
||||
mail_from = envelope.mail_from
|
||||
# Only alias's owning mailbox can send the unsubscribe request
|
||||
mailbox = get_mailbox_from_mail_from(mail_from, alias)
|
||||
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
|
||||
|
||||
user = alias.user
|
||||
|
|
|
@ -174,4 +174,8 @@ DISABLE_ONBOARDING=true
|
|||
#ALIAS_AUTOMATIC_DISABLE=true
|
||||
|
||||
# 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"
|
||||
|
|
|
@ -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 ###
|
|
@ -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:
|
||||
log_postfix_metrics()
|
||||
log_nb_db_connection()
|
||||
Session.close()
|
||||
|
||||
# 1 min
|
||||
sleep(60)
|
||||
|
|
12
server.py
12
server.py
|
@ -33,11 +33,7 @@ from app.admin_model import (
|
|||
EmailLogAdmin,
|
||||
AliasAdmin,
|
||||
MailboxAdmin,
|
||||
LifetimeCouponAdmin,
|
||||
ManualSubscriptionAdmin,
|
||||
ClientAdmin,
|
||||
ReferralAdmin,
|
||||
PayoutAdmin,
|
||||
CouponAdmin,
|
||||
CustomDomainAdmin,
|
||||
AdminAuditLogAdmin,
|
||||
|
@ -82,20 +78,16 @@ from app.fake_data import fake_data
|
|||
from app.jose_utils import get_jwk_key
|
||||
from app.log import LOG
|
||||
from app.models import (
|
||||
Client,
|
||||
User,
|
||||
Alias,
|
||||
Subscription,
|
||||
PlanEnum,
|
||||
CustomDomain,
|
||||
LifetimeCoupon,
|
||||
Mailbox,
|
||||
Referral,
|
||||
CoinbaseSubscription,
|
||||
EmailLog,
|
||||
Contact,
|
||||
ManualSubscription,
|
||||
Payout,
|
||||
Coupon,
|
||||
AdminAuditLog,
|
||||
)
|
||||
|
@ -695,13 +687,9 @@ def init_admin(app):
|
|||
admin.add_view(AliasAdmin(Alias, Session))
|
||||
admin.add_view(MailboxAdmin(Mailbox, Session))
|
||||
admin.add_view(EmailLogAdmin(EmailLog, Session))
|
||||
admin.add_view(LifetimeCouponAdmin(LifetimeCoupon, Session))
|
||||
admin.add_view(CouponAdmin(Coupon, Session))
|
||||
admin.add_view(ManualSubscriptionAdmin(ManualSubscription, Session))
|
||||
admin.add_view(ClientAdmin(Client, 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))
|
||||
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
<!-- 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 %}
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='darkmode.css') }}?v={{ VERSION }}">
|
||||
|
|
|
@ -44,13 +44,16 @@
|
|||
</div>
|
||||
{% 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">
|
||||
<label class="form-label">PGP Public Key</label>
|
||||
|
||||
<textarea name="pgp"
|
||||
{% 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>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" name="action"
|
||||
|
@ -70,4 +73,9 @@
|
|||
</div>
|
||||
{% 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>
|
||||
{% 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"
|
||||
title="Alias can't receive emails as its domain doesn't have MX records set up."></span>
|
||||
{% endif %}
|
||||
|
|
|
@ -117,14 +117,16 @@
|
|||
</div>
|
||||
{% 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">
|
||||
<div class="form-group">
|
||||
<label class="form-label">PGP Public Key</label>
|
||||
|
||||
<textarea name="pgp"
|
||||
{% 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>
|
||||
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="form-name" value="pgp">
|
||||
|
@ -263,10 +265,12 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script src="/static/js/utils/drag-drop-into-text.js"></script>
|
||||
<script>
|
||||
$(".custom-switch-input").change(function (e) {
|
||||
$(this).closest("form").submit();
|
||||
});
|
||||
enableDragDropForPGPKeys('#pgp-public-key');
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -520,6 +520,30 @@
|
|||
</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-body">
|
||||
<div class="card-title">Alias Import</div>
|
||||
|
|
|
@ -19,9 +19,5 @@
|
|||
The email is automatically deleted in 7 days.
|
||||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
If you have any question, please reply to this email.
|
||||
{% endcall %}
|
||||
|
||||
{{ render_text('Thanks, <br />SimpleLogin Team.') }}
|
||||
{% endblock %}
|
||||
|
|
|
@ -38,15 +38,13 @@
|
|||
|
||||
<div class="dropdown-item d-flex" v-for="notification in notifications">
|
||||
<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>
|
||||
|
||||
|
||||
<div v-html="notification.message"
|
||||
style="width: 40em; word-wrap:break-word; white-space: normal; overflow: hidden; max-height: 100px; text-overflow: ellipsis;">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div v-if="notification.title">
|
||||
<a :href="'/dashboard/notification/' + notification.id">More</a>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -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(
|
||||
[(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)
|
||||
Session.commit()
|
||||
|
||||
assert not should_disable(alias)
|
||||
assert not should_disable(alias)[0]
|
||||
|
||||
# create a lot of bounce on this alias
|
||||
contact = Contact.create(
|
||||
|
@ -629,12 +629,12 @@ def test_should_disable(flask_client):
|
|||
bounced=True,
|
||||
)
|
||||
|
||||
assert should_disable(alias)
|
||||
assert should_disable(alias)[0]
|
||||
|
||||
# should not affect another alias
|
||||
alias2 = Alias.create_new_random(user)
|
||||
Session.commit()
|
||||
assert not should_disable(alias2)
|
||||
assert not should_disable(alias2)[0]
|
||||
|
||||
|
||||
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)
|
||||
Session.commit()
|
||||
|
||||
assert not should_disable(alias)
|
||||
assert not should_disable(alias)[0]
|
||||
|
||||
# create a lot of bounce on this alias
|
||||
contact = Contact.create(
|
||||
|
@ -663,7 +663,7 @@ def test_should_disable_bounces_every_day(flask_client):
|
|||
created_at=arrow.now().shift(days=-i),
|
||||
)
|
||||
|
||||
assert should_disable(alias)
|
||||
assert should_disable(alias)[0]
|
||||
|
||||
|
||||
def test_should_disable_bounces_account(flask_client):
|
||||
|
@ -682,8 +682,8 @@ def test_should_disable_bounces_account(flask_client):
|
|||
commit=True,
|
||||
)
|
||||
|
||||
for day in range(6):
|
||||
for _ in range(10):
|
||||
for day in range(5):
|
||||
for _ in range(11):
|
||||
EmailLog.create(
|
||||
user_id=user.id,
|
||||
contact_id=contact.id,
|
||||
|
@ -694,7 +694,7 @@ def test_should_disable_bounces_account(flask_client):
|
|||
)
|
||||
|
||||
alias2 = Alias.create_new_random(user)
|
||||
assert should_disable(alias2)
|
||||
assert should_disable(alias2)[0]
|
||||
|
||||
|
||||
def test_should_disable_bounce_consecutive_days(flask_client):
|
||||
|
@ -719,10 +719,10 @@ def test_should_disable_bounce_consecutive_days(flask_client):
|
|||
commit=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
|
||||
for _ in range(2):
|
||||
# create +10 bounces in the last 7 days: alias should be disabled
|
||||
for _ in range(11):
|
||||
EmailLog.create(
|
||||
user_id=user.id,
|
||||
contact_id=contact.id,
|
||||
|
@ -731,7 +731,7 @@ def test_should_disable_bounce_consecutive_days(flask_client):
|
|||
bounced=True,
|
||||
created_at=arrow.now().shift(days=-3),
|
||||
)
|
||||
assert should_disable(alias)
|
||||
assert should_disable(alias)[0]
|
||||
|
||||
|
||||
def test_parse_id_from_bounce():
|
||||
|
|
Loading…
Reference in New Issue