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:
Adrià Casajús 2022-03-10 18:10:13 +01:00
commit d0860cd54d
No known key found for this signature in database
GPG Key ID: F0033226A5AFC9B9
29 changed files with 354 additions and 199 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

@ -50,6 +50,7 @@ if __name__ == "__main__":
while True:
log_postfix_metrics()
log_nb_db_connection()
Session.close()
# 1 min
sleep(60)

View File

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

View 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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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