Compare commits

...

7 Commits

Author SHA1 Message Date
01dba12ed0 4.59.3
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m43s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m57s
Build-Release-Image / Merge-Images (push) Successful in 53s
Build-Release-Image / Create-Release (push) Successful in 8s
Build-Release-Image / Notify (push) Successful in 3s
2024-11-16 12:00:07 +00:00
c872d43c3d 4.59.2
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m7s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 4m46s
Build-Release-Image / Merge-Images (push) Successful in 14s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 5s
2024-11-14 12:00:07 +00:00
3e6867bc17 4.58
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m7s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m49s
Build-Release-Image / Merge-Images (push) Successful in 15s
Build-Release-Image / Create-Release (push) Successful in 8s
Build-Release-Image / Notify (push) Successful in 3s
2024-11-07 12:00:06 +00:00
a829074584 4.57.2
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m6s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m48s
Build-Release-Image / Merge-Images (push) Successful in 20s
Build-Release-Image / Create-Release (push) Successful in 11s
Build-Release-Image / Notify (push) Successful in 2s
2024-11-06 12:00:08 +00:00
25834e8f61 4.56.3
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m15s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m45s
Build-Release-Image / Merge-Images (push) Successful in 15s
Build-Release-Image / Create-Release (push) Successful in 10s
Build-Release-Image / Notify (push) Successful in 21s
2024-11-05 12:00:07 +00:00
a62b43b7c4 4.56.1
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m30s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m37s
Build-Release-Image / Merge-Images (push) Successful in 22s
Build-Release-Image / Create-Release (push) Successful in 24s
Build-Release-Image / Notify (push) Successful in 3s
2024-10-25 12:00:05 +01:00
44fda2d94e 4.56.0
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m24s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m34s
Build-Release-Image / Merge-Images (push) Successful in 14s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 3s
2024-10-24 12:00:05 +01:00
50 changed files with 1211 additions and 389 deletions

View File

@ -84,7 +84,7 @@ For email gurus, we have chosen 1024 key length instead of 2048 for DNS simplici
### DNS ### DNS
Please note that DNS changes could take up to 24 hours to propagate. In practice, it's a lot faster though (~1 minute or so in our test). In DNS setup, we usually use domain with a trailing dot (`.`) at the end to to force using absolute domain. Please note that DNS changes could take up to 24 hours to propagate. In practice, it's a lot faster though (~1 minute or so in our test). In DNS setup, we usually use domain with a trailing dot (`.`) at the end to force using absolute domain.
#### MX record #### MX record

View File

@ -7,8 +7,4 @@ If you want be up to date on security patches, make sure your SimpleLogin image
## Reporting a Vulnerability ## Reporting a Vulnerability
If you've found a security vulnerability, you can disclose it responsibly by sending a summary to security@simplelogin.io. If you want to report a vulnerability, please take a look at our bug bounty program at https://proton.me/security/bug-bounty.
We will review the potential threat and fix it as fast as we can.
We are incredibly thankful for people who disclose vulnerabilities, unfortunately we do not have a bounty program in place yet.

View File

@ -3,12 +3,16 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
import arrow
from arrow import Arrow from arrow import Arrow
from newrelic import agent from newrelic import agent
from psycopg2.errors import UniqueViolation
from sqlalchemy import or_ from sqlalchemy import or_
from app.db import Session from app.db import Session
from app.email_utils import send_welcome_email from app.email_utils import send_welcome_email
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import UserPlanChanged, EventContent
from app.partner_user_utils import create_partner_user, create_partner_subscription from app.partner_user_utils import create_partner_user, create_partner_subscription
from app.utils import sanitize_email, canonicalize_email from app.utils import sanitize_email, canonicalize_email
from app.errors import ( from app.errors import (
@ -54,6 +58,21 @@ class LinkResult:
strategy: str strategy: str
def send_user_plan_changed_event(partner_user: PartnerUser) -> Optional[int]:
subscription_end = partner_user.user.get_active_subscription_end(
include_partner_subscription=False
)
end_timestamp = None
if partner_user.user.lifetime:
end_timestamp = arrow.get("2038-01-01").timestamp
elif subscription_end:
end_timestamp = subscription_end.timestamp
event = UserPlanChanged(plan_end_time=end_timestamp)
EventDispatcher.send_event(partner_user.user, EventContent(user_plan_change=event))
Session.flush()
return end_timestamp
def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan): def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
sub = PartnerSubscription.get_by(partner_user_id=partner_user.id) sub = PartnerSubscription.get_by(partner_user_id=partner_user.id)
if plan.type == SLPlanType.Free: if plan.type == SLPlanType.Free:
@ -88,6 +107,8 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
action=UserAuditLogAction.SubscriptionExtended, action=UserAuditLogAction.SubscriptionExtended,
message="Extended partner subscription", message="Extended partner subscription",
) )
Session.flush()
send_user_plan_changed_event(partner_user)
Session.commit() Session.commit()
@ -112,6 +133,7 @@ def ensure_partner_user_exists_for_user(
partner_email=link_request.email, partner_email=link_request.email,
external_user_id=link_request.external_user_id, external_user_id=link_request.external_user_id,
) )
Session.commit() Session.commit()
LOG.i( LOG.i(
f"Created new partner_user for partner:{partner.id} user:{sl_user.id} external_user_id:{link_request.external_user_id}. PartnerUser.id is {res.id}" f"Created new partner_user for partner:{partner.id} user:{sl_user.id} external_user_id:{link_request.external_user_id}. PartnerUser.id is {res.id}"
@ -139,15 +161,55 @@ class ClientMergeStrategy(ABC):
class NewUserStrategy(ClientMergeStrategy): class NewUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult: def process(self) -> LinkResult:
# Will create a new SL User with a random password
canonical_email = canonicalize_email(self.link_request.email) canonical_email = canonicalize_email(self.link_request.email)
new_user = User.create( try:
email=canonical_email, # Will create a new SL User with a random password
name=self.link_request.name, new_user = User.create(
password=random_string(20), email=canonical_email,
activated=True, name=self.link_request.name,
from_partner=self.link_request.from_partner, password=random_string(20),
activated=True,
from_partner=self.link_request.from_partner,
)
self.create_partner_user(new_user)
Session.commit()
if not new_user.created_by_partner:
send_welcome_email(new_user)
agent.record_custom_event(
"PartnerUserCreation", {"partner": self.partner.name}
)
return LinkResult(
user=new_user,
strategy=self.__class__.__name__,
)
except UniqueViolation:
return self.create_missing_link(canonical_email)
def create_missing_link(self, canonical_email: str):
# If there's a unique key violation due to race conditions try to create only the partner if needed
partner_user = PartnerUser.get_by(
external_user_id=self.link_request.external_user_id,
partner_id=self.partner.id,
) )
if partner_user is None:
# Get the user by canonical email and if not by normal email
user = User.get_by(email=canonical_email) or User.get_by(
email=self.link_request.email
)
if not user:
raise RuntimeError(
"Tried to create only partner on UniqueViolation but cannot find the user"
)
partner_user = self.create_partner_user(user)
Session.commit()
return LinkResult(
user=partner_user.user, strategy=ExistingUnlinkedUserStrategy.__name__
)
def create_partner_user(self, new_user: User):
partner_user = create_partner_user( partner_user = create_partner_user(
user=new_user, user=new_user,
partner_id=self.partner.id, partner_id=self.partner.id,
@ -161,17 +223,7 @@ class NewUserStrategy(ClientMergeStrategy):
partner_user, partner_user,
self.link_request.plan, self.link_request.plan,
) )
Session.commit() return partner_user
if not new_user.created_by_partner:
send_welcome_email(new_user)
agent.record_custom_event("PartnerUserCreation", {"partner": self.partner.name})
return LinkResult(
user=new_user,
strategy=self.__class__.__name__,
)
class ExistingUnlinkedUserStrategy(ClientMergeStrategy): class ExistingUnlinkedUserStrategy(ClientMergeStrategy):

View File

@ -16,6 +16,8 @@ from flask_admin.contrib import sqla
from flask_login import current_user from flask_login import current_user
from app.db import Session from app.db import Session
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import EventContent, UserPlanChanged
from app.models import ( from app.models import (
User, User,
ManualSubscription, ManualSubscription,
@ -39,6 +41,7 @@ from app.models import (
UserAuditLog, UserAuditLog,
) )
from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
def _admin_action_formatter(view, context, model, name): def _admin_action_formatter(view, context, model, name):
@ -351,17 +354,42 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
manual_sub.end_at = manual_sub.end_at.shift(years=1) manual_sub.end_at = manual_sub.end_at.shift(years=1)
else: else:
manual_sub.end_at = arrow.now().shift(years=1, days=1) manual_sub.end_at = arrow.now().shift(years=1, days=1)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} extended manual subscription to user {user.email}",
)
EventDispatcher.send_event(
user=user,
content=EventContent(
user_plan_change=UserPlanChanged(
plan_end_time=manual_sub.end_at.timestamp
)
),
)
flash(f"Subscription extended to {manual_sub.end_at.humanize()}", "success") flash(f"Subscription extended to {manual_sub.end_at.humanize()}", "success")
continue else:
emit_user_audit_log(
user=user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} created manual subscription to user {user.email}",
)
manual_sub = ManualSubscription.create(
user_id=user.id,
end_at=arrow.now().shift(years=1, days=1),
comment=way,
is_giveaway=is_giveaway,
)
EventDispatcher.send_event(
user=user,
content=EventContent(
user_plan_change=UserPlanChanged(
plan_end_time=manual_sub.end_at.timestamp
)
),
)
ManualSubscription.create( flash(f"New {way} manual subscription for {user} is created", "success")
user_id=user.id,
end_at=arrow.now().shift(years=1, days=1),
comment=way,
is_giveaway=is_giveaway,
)
flash(f"New {way} manual subscription for {user} is created", "success")
Session.commit() Session.commit()
@ -453,14 +481,7 @@ class ManualSubscriptionAdmin(SLModelView):
"Extend 1 year more?", "Extend 1 year more?",
) )
def extend_1y(self, ids): def extend_1y(self, ids):
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)): self.__extend_manual_subscription(ids, msg="1 year", years=1)
ms.end_at = ms.end_at.shift(years=1)
flash(f"Extend subscription for 1 year for {ms.user}", "success")
AdminAuditLog.extend_subscription(
current_user.id, ms.user.id, ms.end_at, "1 year"
)
Session.commit()
@action( @action(
"extend_1m", "extend_1m",
@ -468,11 +489,26 @@ class ManualSubscriptionAdmin(SLModelView):
"Extend 1 month more?", "Extend 1 month more?",
) )
def extend_1m(self, ids): def extend_1m(self, ids):
self.__extend_manual_subscription(ids, msg="1 month", months=1)
def __extend_manual_subscription(self, ids: List[int], msg: str, **kwargs):
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(months=1) sub: ManualSubscription = ms
flash(f"Extend subscription for 1 month for {ms.user}", "success") sub.end_at = sub.end_at.shift(**kwargs)
flash(f"Extend subscription for {msg} for {sub.user}", "success")
emit_user_audit_log(
user=sub.user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} extended manual subscription for {msg} for {sub.user}",
)
AdminAuditLog.extend_subscription( AdminAuditLog.extend_subscription(
current_user.id, ms.user.id, ms.end_at, "1 month" current_user.id, sub.user.id, sub.end_at, msg
)
EventDispatcher.send_event(
user=sub.user,
content=EventContent(
user_plan_change=UserPlanChanged(plan_end_time=sub.end_at.timestamp)
),
) )
Session.commit() Session.commit()

View File

@ -58,7 +58,7 @@ def verify_prefix_suffix(
# alias_domain must be either one of user custom domains or built-in domains # alias_domain must be either one of user custom domains or built-in domains
if alias_domain not in user.available_alias_domains(alias_options=alias_options): if alias_domain not in user.available_alias_domains(alias_options=alias_options):
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user) LOG.i("wrong alias suffix %s, user %s", alias_suffix, user)
return False return False
# SimpleLogin domain case: # SimpleLogin domain case:
@ -75,17 +75,17 @@ def verify_prefix_suffix(
and not config.DISABLE_ALIAS_SUFFIX and not config.DISABLE_ALIAS_SUFFIX
): ):
if not alias_domain_prefix.startswith("."): if not alias_domain_prefix.startswith("."):
LOG.e("User %s submits a wrong alias suffix %s", user, alias_suffix) LOG.i("User %s submits a wrong alias suffix %s", user, alias_suffix)
return False return False
else: else:
if alias_domain not in user_custom_domains: if alias_domain not in user_custom_domains:
if not config.DISABLE_ALIAS_SUFFIX: if not config.DISABLE_ALIAS_SUFFIX:
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user) LOG.i("wrong alias suffix %s, user %s", alias_suffix, user)
return False return False
if alias_domain not in available_sl_domains: if alias_domain not in available_sl_domains:
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user) LOG.i("wrong alias suffix %s, user %s", alias_suffix, user)
return False return False
return True return True

View File

@ -419,9 +419,8 @@ def create_contact_route(alias_id):
if not data: if not data:
return jsonify(error="request body cannot be empty"), 400 return jsonify(error="request body cannot be empty"), 400
alias: Alias = Alias.get(alias_id) alias: Optional[Alias] = Alias.get_by(id=alias_id, user_id=g.user.id)
if not alias:
if alias.user_id != g.user.id:
return jsonify(error="Forbidden"), 403 return jsonify(error="Forbidden"), 403
contact_address = data.get("contact") contact_address = data.get("contact")

View File

@ -38,7 +38,11 @@ def create_mailbox():
the new mailbox dict the new mailbox dict
""" """
user = g.user user = g.user
mailbox_email = sanitize_email(request.get_json().get("email")) email = request.get_json().get("email")
if not email:
return jsonify(error="Invalid email"), 400
mailbox_email = sanitize_email(email)
try: try:
new_mailbox = mailbox_utils.create_mailbox(user, mailbox_email).mailbox new_mailbox = mailbox_utils.create_mailbox(user, mailbox_email).mailbox

View File

@ -153,7 +153,8 @@ def new_custom_alias_v3():
if not isinstance(data, dict): if not isinstance(data, dict):
return jsonify(error="request body does not follow the required format"), 400 return jsonify(error="request body does not follow the required format"), 400
alias_prefix = data.get("alias_prefix", "").strip().lower().replace(" ", "") alias_prefix_data = data.get("alias_prefix", "") or ""
alias_prefix = alias_prefix_data.strip().lower().replace(" ", "")
signed_suffix = data.get("signed_suffix", "") or "" signed_suffix = data.get("signed_suffix", "") or ""
signed_suffix = signed_suffix.strip() signed_suffix = signed_suffix.strip()

View File

@ -10,6 +10,7 @@ from app.events.auth_event import LoginEvent
from app.extensions import limiter from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import User from app.models import User
from app.pw_models import PasswordOracle
from app.utils import sanitize_email, sanitize_next_url, canonicalize_email from app.utils import sanitize_email, sanitize_next_url, canonicalize_email
@ -43,6 +44,13 @@ def login():
user = User.get_by(email=email) or User.get_by(email=canonical_email) user = User.get_by(email=email) or User.get_by(email=canonical_email)
if not user or not user.check_password(form.password.data): if not user or not user.check_password(form.password.data):
if not user:
# Do the hash to avoid timing attacks nevertheless
dummy_pw = PasswordOracle()
dummy_pw.password = (
"$2b$12$ZWqpL73h4rGNfLkJohAFAu0isqSw/bX9p/tzpbWRz/To5FAftaW8u"
)
dummy_pw.check_password(form.password.data)
# Trigger rate limiter # Trigger rate limiter
g.deduct_limit = True g.deduct_limit = True
form.password.data = None form.password.data = None

View File

@ -309,6 +309,7 @@ JOB_DELETE_DOMAIN = "delete-domain"
JOB_SEND_USER_REPORT = "send-user-report" JOB_SEND_USER_REPORT = "send-user-report"
JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1" JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1"
JOB_SEND_ALIAS_CREATION_EVENTS = "send-alias-creation-events" JOB_SEND_ALIAS_CREATION_EVENTS = "send-alias-creation-events"
JOB_SEND_EVENT_TO_WEBHOOK = "send-event-to-webhook"
# for pagination # for pagination
PAGE_LIMIT = 20 PAGE_LIMIT = 20

View File

@ -16,6 +16,7 @@ from app.utils import sanitize_email
class ContactCreateError(Enum): class ContactCreateError(Enum):
InvalidEmail = "Invalid email" InvalidEmail = "Invalid email"
NotAllowed = "Your plan does not allow to create contacts" NotAllowed = "Your plan does not allow to create contacts"
Unknown = "Unknown error when trying to create contact"
@dataclass @dataclass
@ -87,8 +88,10 @@ def create_contact(
return __update_contact_if_needed(contact, name, mail_from) return __update_contact_if_needed(contact, name, mail_from)
# Create the contact # Create the contact
reply_email = generate_reply_email(email, alias) reply_email = generate_reply_email(email, alias)
alias_id = alias.id
try: try:
flags = Contact.FLAG_PARTNER_CREATED if from_partner else 0 flags = Contact.FLAG_PARTNER_CREATED if from_partner else 0
is_invalid_email = email == ""
contact = Contact.create( contact = Contact.create(
user_id=alias.user_id, user_id=alias.user_id,
alias_id=alias.id, alias_id=alias.id,
@ -98,9 +101,10 @@ def create_contact(
mail_from=mail_from, mail_from=mail_from,
automatic_created=automatic_created, automatic_created=automatic_created,
flags=flags, flags=flags,
invalid_email=email == "", invalid_email=is_invalid_email,
commit=True, commit=True,
) )
contact_id = contact.id
if automatic_created: if automatic_created:
trail = ". Automatically created" trail = ". Automatically created"
else: else:
@ -108,17 +112,27 @@ def create_contact(
emit_alias_audit_log( emit_alias_audit_log(
alias=alias, alias=alias,
action=AliasAuditLogAction.CreateContact, action=AliasAuditLogAction.CreateContact,
message=f"Created contact {contact.id} ({contact.email}){trail}", message=f"Created contact {contact_id} ({email}){trail}",
commit=True, commit=True,
) )
LOG.d( LOG.d(
f"Created contact {contact} for alias {alias} with email {email} invalid_email={contact.invalid_email}" f"Created contact {contact} for alias {alias} with email {email} invalid_email={is_invalid_email}"
) )
return ContactCreateResult(contact, created=True, error=None)
except IntegrityError: except IntegrityError:
Session.rollback() Session.rollback()
LOG.info( LOG.info(
f"Contact with email {email} for alias_id {alias.id} already existed, fetching from DB" f"Contact with email {email} for alias_id {alias_id} already existed, fetching from DB"
) )
contact = Contact.get_by(alias_id=alias.id, website_email=email) contact: Optional[Contact] = Contact.get_by(
return __update_contact_if_needed(contact, name, mail_from) alias_id=alias_id, website_email=email
return ContactCreateResult(contact, created=True, error=None) )
if contact:
return __update_contact_if_needed(contact, name, mail_from)
else:
LOG.warning(
f"Could not find contact with email {email} for alias_id {alias_id} and it should exist"
)
return ContactCreateResult(
None, created=False, error=ContactCreateError.Unknown
)

View File

@ -1,3 +1,5 @@
import secrets
import arrow import arrow
from flask import ( from flask import (
render_template, render_template,
@ -163,7 +165,7 @@ def send_reset_password_email(user):
""" """
# the activation code is valid for 1h # the activation code is valid for 1h
reset_password_code = ResetPasswordCode.create( reset_password_code = ResetPasswordCode.create(
user_id=user.id, code=random_string(60) user_id=user.id, code=secrets.token_urlsafe(32)
) )
Session.commit() Session.commit()

View File

@ -227,7 +227,10 @@ def alias_contact_manager(alias_id):
page = 0 page = 0
if request.args.get("page"): if request.args.get("page"):
page = int(request.args.get("page")) try:
page = int(request.args.get("page"))
except ValueError:
pass
query = request.args.get("query") or "" query = request.args.get("query") or ""

View File

@ -71,7 +71,10 @@ def index():
page = 0 page = 0
if request.args.get("page"): if request.args.get("page"):
page = int(request.args.get("page")) try:
page = int(request.args.get("page"))
except ValueError:
pass
highlight_alias_id = None highlight_alias_id = None
if request.args.get("highlight_alias_id"): if request.args.get("highlight_alias_id"):

View File

@ -1,3 +1,4 @@
import arrow
from flask import render_template, flash, redirect, url_for from flask import render_template, flash, redirect, url_for
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
@ -7,6 +8,8 @@ from app.config import ADMIN_EMAIL
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
from app.email_utils import send_email from app.email_utils import send_email
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import UserPlanChanged, EventContent
from app.models import LifetimeCoupon from app.models import LifetimeCoupon
@ -40,6 +43,14 @@ def lifetime_licence():
current_user.lifetime_coupon_id = coupon.id current_user.lifetime_coupon_id = coupon.id
if coupon.paid: if coupon.paid:
current_user.paid_lifetime = True current_user.paid_lifetime = True
EventDispatcher.send_event(
user=current_user,
content=EventContent(
user_plan_change=UserPlanChanged(
plan_end_time=arrow.get("2038-01-01").timestamp
)
),
)
Session.commit() Session.commit()
# notify admin # notify admin

View File

@ -121,10 +121,16 @@ def mailbox_route():
@login_required @login_required
def mailbox_verify(): def mailbox_verify():
mailbox_id = request.args.get("mailbox_id") mailbox_id = request.args.get("mailbox_id")
if not mailbox_id:
LOG.i("Missing mailbox_id")
flash("You followed an invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
code = request.args.get("code") code = request.args.get("code")
if not code: if not code:
# Old way # Old way
return verify_with_signed_secret(mailbox_id) return verify_with_signed_secret(mailbox_id)
try: try:
mailbox = mailbox_utils.verify_mailbox_code(current_user, mailbox_id, code) mailbox = mailbox_utils.verify_mailbox_code(current_user, mailbox_id, code)
except mailbox_utils.MailboxError as e: except mailbox_utils.MailboxError as e:

View File

@ -43,7 +43,10 @@ def notification_route(notification_id):
def notifications_route(): def notifications_route():
page = 0 page = 0
if request.args.get("page"): if request.args.get("page"):
page = int(request.args.get("page")) try:
page = int(request.args.get("page"))
except ValueError:
pass
notifications = ( notifications = (
Notification.filter_by(user_id=current_user.id) Notification.filter_by(user_id=current_user.id)

View File

@ -0,0 +1,70 @@
from __future__ import annotations
import base64
from typing import Optional
import arrow
from app import config
from app.errors import ProtonPartnerNotSetUp
from app.events.generated import event_pb2
from app.events.generated.event_pb2 import EventContent
from app.models import (
User,
Job,
PartnerUser,
)
from app.proton.utils import get_proton_partner
from events.event_sink import EventSink
class SendEventToWebhookJob:
def __init__(self, user: User, event: EventContent):
self._user: User = user
self._event: EventContent = event
def run(self, sink: EventSink) -> bool:
# Check if the current user has a partner_id
try:
proton_partner_id = get_proton_partner().id
except ProtonPartnerNotSetUp:
return False
# It has. Retrieve the information for the PartnerUser
partner_user = PartnerUser.get_by(
user_id=self._user.id, partner_id=proton_partner_id
)
if partner_user is None:
return True
event = event_pb2.Event(
user_id=self._user.id,
external_user_id=partner_user.external_user_id,
partner_id=partner_user.partner_id,
content=self._event,
)
serialized = event.SerializeToString()
return sink.send_data_to_webhook(serialized)
@staticmethod
def create_from_job(job: Job) -> Optional[SendEventToWebhookJob]:
user = User.get(job.payload["user_id"])
if not user:
return None
event_data = base64.b64decode(job.payload["event"])
event = event_pb2.EventContent()
event.ParseFromString(event_data)
return SendEventToWebhookJob(user=user, event=event)
def store_job_in_db(self, run_at: Optional[arrow.Arrow]) -> Job:
stub = self._event.SerializeToString()
return Job.create(
name=config.JOB_SEND_EVENT_TO_WEBHOOK,
payload={
"user_id": self._user.id,
"event": base64.b64encode(stub).decode("utf-8"),
},
run_at=run_at if run_at is not None else arrow.now(),
commit=True,
)

View File

@ -1,6 +1,5 @@
import dataclasses import dataclasses
import secrets import secrets
import random
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
import arrow import arrow
@ -37,8 +36,9 @@ class OnlyPaidError(MailboxError):
class CannotVerifyError(MailboxError): class CannotVerifyError(MailboxError):
def __init__(self, msg: str): def __init__(self, msg: str, deleted_activation_code: bool = False):
self.msg = msg self.msg = msg
self.deleted_activation_code = deleted_activation_code
MAX_ACTIVATION_TRIES = 3 MAX_ACTIVATION_TRIES = 3
@ -171,17 +171,17 @@ def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox:
f"User {user} failed to verify mailbox {mailbox_id} because it does not exist" f"User {user} failed to verify mailbox {mailbox_id} because it does not exist"
) )
raise MailboxError("Invalid mailbox") raise MailboxError("Invalid mailbox")
if mailbox.user_id != user.id:
LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's owned by another user"
)
raise MailboxError("Invalid mailbox")
if mailbox.verified: if mailbox.verified:
LOG.i( LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's already verified" f"User {user} failed to verify mailbox {mailbox_id} because it's already verified"
) )
clear_activation_codes_for_mailbox(mailbox) clear_activation_codes_for_mailbox(mailbox)
return mailbox return mailbox
if mailbox.user_id != user.id:
LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's owned by another user"
)
raise MailboxError("Invalid mailbox")
activation = ( activation = (
MailboxActivation.filter(MailboxActivation.mailbox_id == mailbox_id) MailboxActivation.filter(MailboxActivation.mailbox_id == mailbox_id)
@ -196,7 +196,10 @@ def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox:
if activation.tries >= MAX_ACTIVATION_TRIES: if activation.tries >= MAX_ACTIVATION_TRIES:
LOG.i(f"User {user} failed to verify mailbox {mailbox_id} more than 3 times") LOG.i(f"User {user} failed to verify mailbox {mailbox_id} more than 3 times")
clear_activation_codes_for_mailbox(mailbox) clear_activation_codes_for_mailbox(mailbox)
raise CannotVerifyError("Invalid activation code. Please request another code.") raise CannotVerifyError(
"Invalid activation code. Please request another code.",
deleted_activation_code=True,
)
if activation.created_at < arrow.now().shift(minutes=-15): if activation.created_at < arrow.now().shift(minutes=-15):
LOG.i( LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because code is too old" f"User {user} failed to verify mailbox {mailbox_id} because code is too old"
@ -229,7 +232,7 @@ def generate_activation_code(
if config.MAILBOX_VERIFICATION_OVERRIDE_CODE: if config.MAILBOX_VERIFICATION_OVERRIDE_CODE:
code = config.MAILBOX_VERIFICATION_OVERRIDE_CODE code = config.MAILBOX_VERIFICATION_OVERRIDE_CODE
else: else:
code = "{:06d}".format(random.randint(1, 999999)) code = "{:06d}".format(secrets.randbelow(1000000))[:6]
else: else:
code = secrets.token_urlsafe(16) code = secrets.token_urlsafe(16)
return MailboxActivation.create( return MailboxActivation.create(

View File

@ -24,6 +24,7 @@ from sqlalchemy import text, desc, CheckConstraint, Index, Column
from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import deferred from sqlalchemy.orm import deferred
from sqlalchemy.orm.exc import ObjectDeletedError
from sqlalchemy.sql import and_ from sqlalchemy.sql import and_
from sqlalchemy_utils import ArrowType from sqlalchemy_utils import ArrowType
@ -157,6 +158,8 @@ class File(Base, ModelMixin):
path = sa.Column(sa.String(128), unique=True, nullable=False) path = sa.Column(sa.String(128), unique=True, nullable=False)
user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=True) user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=True)
__table_args__ = (sa.Index("ix_file_user_id", "user_id"),)
def get_url(self, expires_in=3600): def get_url(self, expires_in=3600):
return s3.get_url(self.path, expires_in) return s3.get_url(self.path, expires_in)
@ -318,6 +321,8 @@ class HibpNotifiedAlias(Base, ModelMixin):
notified_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False) notified_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False)
__table_args__ = (sa.Index("ix_hibp_notified_alias_user_id", "user_id"),)
class Fido(Base, ModelMixin): class Fido(Base, ModelMixin):
__tablename__ = "fido" __tablename__ = "fido"
@ -332,6 +337,8 @@ class Fido(Base, ModelMixin):
name = sa.Column(sa.String(128), nullable=False, unique=False) name = sa.Column(sa.String(128), nullable=False, unique=False)
user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=True) user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=True)
__table_args__ = (sa.Index("ix_fido_user_id", "user_id"),)
class User(Base, ModelMixin, UserMixin, PasswordOracle): class User(Base, ModelMixin, UserMixin, PasswordOracle):
__tablename__ = "users" __tablename__ = "users"
@ -564,6 +571,11 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
"ix_users_activated_trial_end_lifetime", activated, trial_end, lifetime "ix_users_activated_trial_end_lifetime", activated, trial_end, lifetime
), ),
sa.Index("ix_users_delete_on", delete_on), sa.Index("ix_users_delete_on", delete_on),
sa.Index("ix_users_default_mailbox_id", default_mailbox_id),
sa.Index(
"ix_users_default_alias_custom_domain_id", default_alias_custom_domain_id
),
sa.Index("ix_users_profile_picture_id", profile_picture_id),
) )
@property @property
@ -1220,6 +1232,8 @@ class ActivationCode(Base, ModelMixin):
expired = sa.Column(ArrowType, nullable=False, default=_expiration_1h) expired = sa.Column(ArrowType, nullable=False, default=_expiration_1h)
__table_args__ = (sa.Index("ix_activation_code_user_id", "user_id"),)
def is_expired(self): def is_expired(self):
return self.expired < arrow.now() return self.expired < arrow.now()
@ -1236,6 +1250,8 @@ class ResetPasswordCode(Base, ModelMixin):
expired = sa.Column(ArrowType, nullable=False, default=_expiration_1h) expired = sa.Column(ArrowType, nullable=False, default=_expiration_1h)
__table_args__ = (sa.Index("ix_reset_password_code_user_id", "user_id"),)
def is_expired(self): def is_expired(self):
return self.expired < arrow.now() return self.expired < arrow.now()
@ -1278,6 +1294,8 @@ class MfaBrowser(Base, ModelMixin):
user = orm.relationship(User) user = orm.relationship(User)
__table_args__ = (sa.Index("ix_mfa_browser_user_id", "user_id"),)
@classmethod @classmethod
def create_new(cls, user, token_length=64) -> "MfaBrowser": def create_new(cls, user, token_length=64) -> "MfaBrowser":
found = False found = False
@ -1336,6 +1354,12 @@ class Client(Base, ModelMixin):
user = orm.relationship(User) user = orm.relationship(User)
referral = orm.relationship("Referral") referral = orm.relationship("Referral")
__table_args__ = (
sa.Index("ix_client_user_id", "user_id"),
sa.Index("ix_client_icon_id", "icon_id"),
sa.Index("ix_client_referral_id", "referral_id"),
)
def nb_user(self): def nb_user(self):
return ClientUser.filter_by(client_id=self.id).count() return ClientUser.filter_by(client_id=self.id).count()
@ -1384,6 +1408,8 @@ class RedirectUri(Base, ModelMixin):
client = orm.relationship(Client, backref="redirect_uris") client = orm.relationship(Client, backref="redirect_uris")
__table_args__ = (sa.Index("ix_redirect_uri_client_id", "client_id"),)
class AuthorizationCode(Base, ModelMixin): class AuthorizationCode(Base, ModelMixin):
__tablename__ = "authorization_code" __tablename__ = "authorization_code"
@ -1405,6 +1431,11 @@ class AuthorizationCode(Base, ModelMixin):
expired = sa.Column(ArrowType, nullable=False, default=_expiration_5m) expired = sa.Column(ArrowType, nullable=False, default=_expiration_5m)
__table_args__ = (
sa.Index("ix_authorization_code_client_id", "client_id"),
sa.Index("ix_authorization_code_user_id", "user_id"),
)
def is_expired(self): def is_expired(self):
return self.expired < arrow.now() return self.expired < arrow.now()
@ -1427,6 +1458,11 @@ class OauthToken(Base, ModelMixin):
expired = sa.Column(ArrowType, nullable=False, default=_expiration_1h) expired = sa.Column(ArrowType, nullable=False, default=_expiration_1h)
__table_args__ = (
sa.Index("ix_oauth_token_user_id", "user_id"),
sa.Index("ix_oauth_token_client_id", "client_id"),
)
def is_expired(self): def is_expired(self):
return self.expired < arrow.now() return self.expired < arrow.now()
@ -1580,6 +1616,7 @@ class Alias(Base, ModelMixin):
postgresql_ops={"note": "gin_trgm_ops"}, postgresql_ops={"note": "gin_trgm_ops"},
postgresql_using="gin", postgresql_using="gin",
), ),
Index("ix_alias_original_owner_id", "original_owner_id"),
) )
user = orm.relationship(User, foreign_keys=[user_id]) user = orm.relationship(User, foreign_keys=[user_id])
@ -1665,6 +1702,11 @@ class Alias(Base, ModelMixin):
custom_domain = Alias.get_custom_domain(email) custom_domain = Alias.get_custom_domain(email)
if custom_domain: if custom_domain:
new_alias.custom_domain_id = custom_domain.id new_alias.custom_domain_id = custom_domain.id
else:
custom_domain = CustomDomain.get(kw["custom_domain_id"])
# If it comes from a custom domain created from partner. Mark it as created from partner
if custom_domain is not None and custom_domain.partner_id is not None:
new_alias.flags = (new_alias.flags or 0) | Alias.FLAG_PARTNER_CREATED
Session.add(new_alias) Session.add(new_alias)
DailyMetric.get_or_create_today_metric().nb_alias += 1 DailyMetric.get_or_create_today_metric().nb_alias += 1
@ -2068,7 +2110,12 @@ class Contact(Base, ModelMixin):
class EmailLog(Base, ModelMixin): class EmailLog(Base, ModelMixin):
__tablename__ = "email_log" __tablename__ = "email_log"
__table_args__ = (Index("ix_email_log_created_at", "created_at"),) __table_args__ = (
Index("ix_email_log_created_at", "created_at"),
Index("ix_email_log_mailbox_id", "mailbox_id"),
Index("ix_email_log_bounced_mailbox_id", "bounced_mailbox_id"),
Index("ix_email_log_refused_email_id", "refused_email_id"),
)
user_id = sa.Column( user_id = sa.Column(
sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True
@ -2344,6 +2391,7 @@ class AliasUsedOn(Base, ModelMixin):
__table_args__ = ( __table_args__ = (
sa.UniqueConstraint("alias_id", "hostname", name="uq_alias_used"), sa.UniqueConstraint("alias_id", "hostname", name="uq_alias_used"),
sa.Index("ix_alias_used_on_user_id", "user_id"),
) )
alias_id = sa.Column( alias_id = sa.Column(
@ -2370,6 +2418,11 @@ class ApiKey(Base, ModelMixin):
user = orm.relationship(User) user = orm.relationship(User)
__table_args__ = (
sa.Index("ix_api_key_code", "code"),
sa.Index("ix_api_key_user_id", "user_id"),
)
@classmethod @classmethod
def create(cls, user_id, name=None, **kwargs): def create(cls, user_id, name=None, **kwargs):
code = random_string(60) code = random_string(60)
@ -2528,6 +2581,7 @@ class AutoCreateRule(Base, ModelMixin):
sa.UniqueConstraint( sa.UniqueConstraint(
"custom_domain_id", "order", name="uq_auto_create_rule_order" "custom_domain_id", "order", name="uq_auto_create_rule_order"
), ),
sa.Index("ix_auto_create_rule_custom_domain_id", "custom_domain_id"),
) )
custom_domain_id = sa.Column( custom_domain_id = sa.Column(
@ -2571,6 +2625,7 @@ class DomainDeletedAlias(Base, ModelMixin):
__table_args__ = ( __table_args__ = (
sa.UniqueConstraint("domain_id", "email", name="uq_domain_trash"), sa.UniqueConstraint("domain_id", "email", name="uq_domain_trash"),
sa.Index("ix_domain_deleted_alias_user_id", "user_id"),
) )
email = sa.Column(sa.String(256), nullable=False) email = sa.Column(sa.String(256), nullable=False)
@ -2631,6 +2686,8 @@ class Coupon(Base, ModelMixin):
# a coupon can have an expiration # a coupon can have an expiration
expires_date = sa.Column(ArrowType, nullable=True) expires_date = sa.Column(ArrowType, nullable=True)
__table_args__ = (sa.Index("ix_coupon_used_by_user_id", "used_by_user_id"),)
class Directory(Base, ModelMixin): class Directory(Base, ModelMixin):
__tablename__ = "directory" __tablename__ = "directory"
@ -2645,6 +2702,8 @@ class Directory(Base, ModelMixin):
"Mailbox", secondary="directory_mailbox", lazy="joined" "Mailbox", secondary="directory_mailbox", lazy="joined"
) )
__table_args__ = (sa.Index("ix_directory_user_id", "user_id"),)
@property @property
def mailboxes(self): def mailboxes(self):
if self._mailboxes: if self._mailboxes:
@ -2746,7 +2805,10 @@ class Mailbox(Base, ModelMixin):
generic_subject = sa.Column(sa.String(78), nullable=True) generic_subject = sa.Column(sa.String(78), nullable=True)
__table_args__ = (sa.UniqueConstraint("user_id", "email", name="uq_mailbox_user"),) __table_args__ = (
sa.UniqueConstraint("user_id", "email", name="uq_mailbox_user"),
sa.Index("ix_mailbox_pgp_finger_print", "pgp_finger_print"),
)
user = orm.relationship(User, foreign_keys=[user_id]) user = orm.relationship(User, foreign_keys=[user_id])
@ -2883,6 +2945,8 @@ class RefusedEmail(Base, ModelMixin):
# toggle this when email content (stored at full_report_path & path are deleted) # toggle this when email content (stored at full_report_path & path are deleted)
deleted = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0") deleted = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0")
__table_args__ = (sa.Index("ix_refused_email_user_id", "user_id"),)
def get_url(self, expires_in=3600): def get_url(self, expires_in=3600):
if self.path: if self.path:
return s3.get_url(self.path, expires_in) return s3.get_url(self.path, expires_in)
@ -2905,6 +2969,8 @@ class Referral(Base, ModelMixin):
user = orm.relationship(User, foreign_keys=[user_id], backref="referrals") user = orm.relationship(User, foreign_keys=[user_id], backref="referrals")
__table_args__ = (sa.Index("ix_referral_user_id", "user_id"),)
@property @property
def nb_user(self) -> int: def nb_user(self) -> int:
return User.filter_by(referral_id=self.id, activated=True).count() return User.filter_by(referral_id=self.id, activated=True).count()
@ -2944,6 +3010,8 @@ class SentAlert(Base, ModelMixin):
to_email = sa.Column(sa.String(256), nullable=False) to_email = sa.Column(sa.String(256), nullable=False)
alert_type = sa.Column(sa.String(256), nullable=False) alert_type = sa.Column(sa.String(256), nullable=False)
__table_args__ = (sa.Index("ix_sent_alert_user_id", "user_id"),)
class AliasMailbox(Base, ModelMixin): class AliasMailbox(Base, ModelMixin):
__tablename__ = "alias_mailbox" __tablename__ = "alias_mailbox"
@ -3189,6 +3257,11 @@ class BatchImport(Base, ModelMixin):
file = orm.relationship(File) file = orm.relationship(File)
user = orm.relationship(User) user = orm.relationship(User)
__table_args__ = (
sa.Index("ix_batch_import_file_id", "file_id"),
sa.Index("ix_batch_import_user_id", "user_id"),
)
def nb_alias(self): def nb_alias(self):
return Alias.filter_by(batch_import_id=self.id).count() return Alias.filter_by(batch_import_id=self.id).count()
@ -3209,6 +3282,7 @@ class AuthorizedAddress(Base, ModelMixin):
__table_args__ = ( __table_args__ = (
sa.UniqueConstraint("mailbox_id", "email", name="uq_authorize_address"), sa.UniqueConstraint("mailbox_id", "email", name="uq_authorize_address"),
sa.Index("ix_authorized_address_user_id", "user_id"),
) )
mailbox = orm.relationship(Mailbox, backref="authorized_addresses") mailbox = orm.relationship(Mailbox, backref="authorized_addresses")
@ -3350,6 +3424,8 @@ class Payout(Base, ModelMixin):
user = orm.relationship(User) user = orm.relationship(User)
__table_args__ = (sa.Index("ix_payout_user_id", "user_id"),)
class IgnoredEmail(Base, ModelMixin): class IgnoredEmail(Base, ModelMixin):
"""If an email has mail_from and rcpt_to present in this table, discard it by returning 250 status.""" """If an email has mail_from and rcpt_to present in this table, discard it by returning 250 status."""
@ -3451,6 +3527,8 @@ class PhoneReservation(Base, ModelMixin):
start = sa.Column(ArrowType, nullable=False) start = sa.Column(ArrowType, nullable=False)
end = sa.Column(ArrowType, nullable=False) end = sa.Column(ArrowType, nullable=False)
__table_args__ = (sa.Index("ix_phone_reservation_user_id", "user_id"),)
class PhoneMessage(Base, ModelMixin): class PhoneMessage(Base, ModelMixin):
__tablename__ = "phone_message" __tablename__ = "phone_message"
@ -3625,6 +3703,11 @@ class ProviderComplaint(Base, ModelMixin):
user = orm.relationship(User, foreign_keys=[user_id]) user = orm.relationship(User, foreign_keys=[user_id])
refused_email = orm.relationship(RefusedEmail, foreign_keys=[refused_email_id]) refused_email = orm.relationship(RefusedEmail, foreign_keys=[refused_email_id])
__table_args__ = (
sa.Index("ix_provider_complaint_user_id", "user_id"),
sa.Index("ix_provider_complaint_refused_email_id", "refused_email_id"),
)
class PartnerApiToken(Base, ModelMixin): class PartnerApiToken(Base, ModelMixin):
__tablename__ = "partner_api_token" __tablename__ = "partner_api_token"
@ -3748,6 +3831,8 @@ class NewsletterUser(Base, ModelMixin):
user = orm.relationship(User) user = orm.relationship(User)
newsletter = orm.relationship(Newsletter) newsletter = orm.relationship(Newsletter)
__table_args__ = (sa.Index("ix_newsletter_user_user_id", "user_id"),)
class ApiToCookieToken(Base, ModelMixin): class ApiToCookieToken(Base, ModelMixin):
__tablename__ = "api_cookie_token" __tablename__ = "api_cookie_token"
@ -3758,6 +3843,11 @@ class ApiToCookieToken(Base, ModelMixin):
user = orm.relationship(User) user = orm.relationship(User)
api_key = orm.relationship(ApiKey) api_key = orm.relationship(ApiKey)
__table_args__ = (
sa.Index("ix_api_to_cookie_token_api_key_id", "api_key_id"),
sa.Index("ix_api_to_cookie_token_user_id", "user_id"),
)
@classmethod @classmethod
def create(cls, **kwargs): def create(cls, **kwargs):
code = secrets.token_urlsafe(32) code = secrets.token_urlsafe(32)
@ -3781,15 +3871,18 @@ class SyncEvent(Base, ModelMixin):
) )
def mark_as_taken(self, allow_taken_older_than: Optional[Arrow] = None) -> bool: def mark_as_taken(self, allow_taken_older_than: Optional[Arrow] = None) -> bool:
taken_condition = ["taken_time IS NULL"] try:
args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id} taken_condition = ["taken_time IS NULL"]
if allow_taken_older_than: args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id}
taken_condition.append("taken_time < :taken_older_than") if allow_taken_older_than:
args["taken_older_than"] = allow_taken_older_than.datetime taken_condition.append("taken_time < :taken_older_than")
sql_taken_condition = "({})".format(" OR ".join(taken_condition)) args["taken_older_than"] = allow_taken_older_than.datetime
sql = f"UPDATE sync_event SET taken_time = :taken_time WHERE id = :sync_event_id AND {sql_taken_condition}" sql_taken_condition = "({})".format(" OR ".join(taken_condition))
res = Session.execute(sql, args) sql = f"UPDATE sync_event SET taken_time = :taken_time WHERE id = :sync_event_id AND {sql_taken_condition}"
Session.commit() res = Session.execute(sql, args)
Session.commit()
except ObjectDeletedError:
return False
return res.rowcount > 0 return res.rowcount > 0

View File

@ -1,8 +1,10 @@
from typing import Optional from typing import Optional
import arrow
from arrow import Arrow from arrow import Arrow
from app.models import PartnerUser, PartnerSubscription, User from app import config
from app.models import PartnerUser, PartnerSubscription, User, Job
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
@ -15,6 +17,11 @@ def create_partner_user(
partner_email=partner_email, partner_email=partner_email,
external_user_id=external_user_id, external_user_id=external_user_id,
) )
Job.create(
name=config.JOB_SEND_ALIAS_CREATION_EVENTS,
payload={"user_id": user.id},
run_at=arrow.now(),
)
emit_user_audit_log( emit_user_audit_log(
user=user, user=user,
action=UserAuditLogAction.LinkAccount, action=UserAuditLogAction.LinkAccount,

View File

@ -2,11 +2,9 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from flask import url_for from flask import url_for
from typing import Optional from typing import Optional
import arrow
from app import config
from app.errors import LinkException from app.errors import LinkException
from app.models import User, Partner, Job from app.models import User, Partner
from app.proton.proton_client import ProtonClient, ProtonUser from app.proton.proton_client import ProtonClient, ProtonUser
from app.account_linking import ( from app.account_linking import (
process_login_case, process_login_case,
@ -43,21 +41,12 @@ class ProtonCallbackHandler:
def __init__(self, proton_client: ProtonClient): def __init__(self, proton_client: ProtonClient):
self.proton_client = proton_client self.proton_client = proton_client
def _initial_alias_sync(self, user: User):
Job.create(
name=config.JOB_SEND_ALIAS_CREATION_EVENTS,
payload={"user_id": user.id},
run_at=arrow.now(),
commit=True,
)
def handle_login(self, partner: Partner) -> ProtonCallbackResult: def handle_login(self, partner: Partner) -> ProtonCallbackResult:
try: try:
user = self.__get_partner_user() user = self.__get_partner_user()
if user is None: if user is None:
return generate_account_not_allowed_to_log_in() return generate_account_not_allowed_to_log_in()
res = process_login_case(user, partner) res = process_login_case(user, partner)
self._initial_alias_sync(res.user)
return ProtonCallbackResult( return ProtonCallbackResult(
redirect_to_login=False, redirect_to_login=False,
flash_message=None, flash_message=None,
@ -86,7 +75,6 @@ class ProtonCallbackHandler:
if user is None: if user is None:
return generate_account_not_allowed_to_log_in() return generate_account_not_allowed_to_log_in()
res = process_link_case(user, current_user, partner) res = process_link_case(user, current_user, partner)
self._initial_alias_sync(res.user)
return ProtonCallbackResult( return ProtonCallbackResult(
redirect_to_login=False, redirect_to_login=False,
flash_message="Account successfully linked", flash_message="Account successfully linked",

View File

@ -1,4 +1,3 @@
import random
import re import re
import secrets import secrets
import string import string
@ -32,8 +31,9 @@ def random_words(words: int = 2, numbers: int = 0):
fields = [secrets.choice(_words) for i in range(words)] fields = [secrets.choice(_words) for i in range(words)]
if numbers > 0: if numbers > 0:
digits = "".join([str(random.randint(0, 9)) for i in range(numbers)]) digits = [n for n in range(10)]
return "_".join(fields) + digits suffix = "".join([str(secrets.choice(digits)) for i in range(numbers)])
return "_".join(fields) + suffix
else: else:
return "_".join(fields) return "_".join(fields)

View File

@ -286,8 +286,16 @@ def notify_manual_sub_end():
def poll_apple_subscription(): def poll_apple_subscription():
"""Poll Apple API to update AppleSubscription""" """Poll Apple API to update AppleSubscription"""
# todo: only near the end of the subscription for apple_sub in (
for apple_sub in AppleSubscription.all(): AppleSubscription.filter(
AppleSubscription.expires_date < arrow.now().shift(days=15)
)
.enable_eagerloads(False)
.yield_per(100)
):
if not apple_sub.is_valid():
# Subscription is not valid anymore and hasn't been renewed
continue
if not apple_sub.product_id: if not apple_sub.product_id:
LOG.d("Ignore %s", apple_sub) LOG.d("Ignore %s", apple_sub)
continue continue
@ -900,6 +908,24 @@ def check_mailbox_valid_pgp_keys():
def check_custom_domain(): def check_custom_domain():
# Delete custom domains that haven't been verified in a month
for custom_domain in (
CustomDomain.filter(
CustomDomain.verified == False, # noqa: E712
CustomDomain.created_at < arrow.now().shift(months=-1),
)
.enable_eagerloads(False)
.yield_per(100)
):
alias_count = Alias.filter(Alias.custom_domain_id == custom_domain.id).count()
if alias_count > 0:
LOG.warn(
f"Custom Domain {custom_domain} has {alias_count} aliases. Won't delete"
)
else:
LOG.i(f"Deleting unverified old custom domain {custom_domain}")
CustomDomain.delete(custom_domain.id)
LOG.d("Check verified domain for DNS issues") LOG.d("Check verified domain for DNS issues")
for custom_domain in CustomDomain.filter_by(verified=True): # type: CustomDomain for custom_domain in CustomDomain.filter_by(verified=True): # type: CustomDomain
@ -971,7 +997,7 @@ def delete_expired_tokens():
LOG.d("Delete api to cookie tokens older than %s, nb row %s", max_time, nb_row) LOG.d("Delete api to cookie tokens older than %s, nb row %s", max_time, nb_row)
async def _hibp_check(api_key, queue): async def _hibp_check(api_key: str, queue: asyncio.Queue):
""" """
Uses a single API key to check the queue as fast as possible. Uses a single API key to check the queue as fast as possible.
@ -990,11 +1016,16 @@ async def _hibp_check(api_key, queue):
if not alias: if not alias:
continue continue
user = alias.user user = alias.user
if user.disabled or not user.is_paid(): if user.disabled or not user.is_premium():
# Mark it as hibp done to skip it as if it had been checked # Mark it as hibp done to skip it as if it had been checked
alias.hibp_last_check = arrow.utcnow() alias.hibp_last_check = arrow.utcnow()
Session.commit() Session.commit()
continue continue
if alias.flags & Alias.FLAG_PARTNER_CREATED > 0:
# Mark as hibp done
alias.hibp_last_check = arrow.utcnow()
Session.commit()
continue
LOG.d("Checking HIBP for %s", alias) LOG.d("Checking HIBP for %s", alias)

View File

@ -16,13 +16,25 @@ jobs:
shell: /bin/bash shell: /bin/bash
schedule: "15 2 * * *" schedule: "15 2 * * *"
captureStderr: true captureStderr: true
onFailure:
retry:
maximumRetries: 10
initialDelay: 1
maximumDelay: 30
backoffMultiplier: 2
- name: SimpleLogin HIBP check - name: SimpleLogin HIBP check
command: python /code/cron.py -j check_hibp command: python /code/cron.py -j check_hibp
shell: /bin/bash shell: /bin/bash
schedule: "15 3 * * *" schedule: "16 */4 * * *"
captureStderr: true captureStderr: true
concurrencyPolicy: Forbid concurrencyPolicy: Forbid
onFailure:
retry:
maximumRetries: 10
initialDelay: 1
maximumDelay: 30
backoffMultiplier: 2
- name: SimpleLogin Notify HIBP breaches - name: SimpleLogin Notify HIBP breaches
command: python /code/cron.py -j notify_hibp command: python /code/cron.py -j notify_hibp
@ -31,6 +43,7 @@ jobs:
captureStderr: true captureStderr: true
concurrencyPolicy: Forbid concurrencyPolicy: Forbid
- name: SimpleLogin Delete Logs - name: SimpleLogin Delete Logs
command: python /code/cron.py -j delete_logs command: python /code/cron.py -j delete_logs
shell: /bin/bash shell: /bin/bash

View File

@ -177,7 +177,9 @@ from init_app import load_pgp_public_keys
from server import create_light_app from server import create_light_app
def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Contact: def get_or_create_contact(
from_header: str, mail_from: str, alias: Alias
) -> Optional[Contact]:
""" """
contact_from_header is the RFC 2047 format FROM header contact_from_header is the RFC 2047 format FROM header
""" """
@ -208,6 +210,8 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
automatic_created=True, automatic_created=True,
from_partner=False, from_partner=False,
) )
if contact_result.error:
LOG.w(f"Error creating contact: {contact_result.error.value}")
return contact_result.contact return contact_result.contact
@ -558,7 +562,7 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
if not user.is_active(): if not user.is_active():
LOG.w(f"User {user} has been soft deleted") LOG.w(f"User {user} has been soft deleted")
return False, status.E502 return [(False, status.E502)]
if not user.can_send_or_receive(): if not user.can_send_or_receive():
LOG.i(f"User {user} cannot receive emails") LOG.i(f"User {user} cannot receive emails")
@ -579,6 +583,8 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
from_header = get_header_unicode(msg[headers.FROM]) from_header = get_header_unicode(msg[headers.FROM])
LOG.d("Create or get contact for from_header:%s", from_header) LOG.d("Create or get contact for from_header:%s", from_header)
contact = get_or_create_contact(from_header, envelope.mail_from, alias) contact = get_or_create_contact(from_header, envelope.mail_from, alias)
if not contact:
return [(False, status.E504)]
alias = ( alias = (
contact.alias contact.alias
) # In case the Session was closed in the get_or_create we re-fetch the alias ) # In case the Session was closed in the get_or_create we re-fetch the alias

View File

@ -12,6 +12,10 @@ class EventSink(ABC):
def process(self, event: SyncEvent) -> bool: def process(self, event: SyncEvent) -> bool:
pass pass
@abstractmethod
def send_data_to_webhook(self, data: bytes) -> bool:
pass
class HttpEventSink(EventSink): class HttpEventSink(EventSink):
def process(self, event: SyncEvent) -> bool: def process(self, event: SyncEvent) -> bool:
@ -21,9 +25,16 @@ class HttpEventSink(EventSink):
LOG.info(f"Sending event {event.id} to {EVENT_WEBHOOK}") LOG.info(f"Sending event {event.id} to {EVENT_WEBHOOK}")
if self.send_data_to_webhook(event.content):
LOG.info(f"Event {event.id} sent successfully to webhook")
return True
return False
def send_data_to_webhook(self, data: bytes) -> bool:
res = requests.post( res = requests.post(
url=EVENT_WEBHOOK, url=EVENT_WEBHOOK,
data=event.content, data=data,
headers={"Content-Type": "application/x-protobuf"}, headers={"Content-Type": "application/x-protobuf"},
verify=not EVENT_WEBHOOK_SKIP_VERIFY_SSL, verify=not EVENT_WEBHOOK_SKIP_VERIFY_SSL,
) )
@ -36,7 +47,6 @@ class HttpEventSink(EventSink):
) )
return False return False
else: else:
LOG.info(f"Event {event.id} sent successfully to webhook")
return True return True
@ -44,3 +54,7 @@ class ConsoleEventSink(EventSink):
def process(self, event: SyncEvent) -> bool: def process(self, event: SyncEvent) -> bool:
LOG.info(f"Handling event {event.id}") LOG.info(f"Handling event {event.id}")
return True return True
def send_data_to_webhook(self, data: bytes) -> bool:
LOG.info(f"Sending {len(data)} bytes to webhook")
return True

View File

@ -18,6 +18,7 @@ from app.events.event_dispatcher import PostgresDispatcher
from app.import_utils import handle_batch_import from app.import_utils import handle_batch_import
from app.jobs.event_jobs import send_alias_creation_events_for_user from app.jobs.event_jobs import send_alias_creation_events_for_user
from app.jobs.export_user_data_job import ExportUserDataJob from app.jobs.export_user_data_job import ExportUserDataJob
from app.jobs.send_event_job import SendEventToWebhookJob
from app.log import LOG from app.log import LOG
from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
@ -300,6 +301,10 @@ def process_job(job: Job):
send_alias_creation_events_for_user( send_alias_creation_events_for_user(
user, dispatcher=PostgresDispatcher.get() user, dispatcher=PostgresDispatcher.get()
) )
elif job.name == config.JOB_SEND_EVENT_TO_WEBHOOK:
send_job = SendEventToWebhookJob.create_from_job(job)
if send_job:
send_job.run()
else: else:
LOG.e("Unknown job name %s", job.name) LOG.e("Unknown job name %s", job.name)

View File

@ -7000,7 +7000,6 @@ unfunded
unglazed unglazed
ungloved ungloved
unglue unglue
ungodly
ungraded ungraded
ungreased ungreased
unguarded unguarded

View File

@ -0,0 +1,28 @@
"""Preserve user id on alias delete
Revision ID: 4882cc49dde9
Revises: 32f25cbf12f6
Create Date: 2024-11-06 10:10:40.235991
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4882cc49dde9'
down_revision = '32f25cbf12f6'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('deleted_alias', sa.Column('user_id', sa.Integer(), server_default=None, nullable=True))
with op.get_context().autocommit_block():
op.create_index('ix_deleted_alias_user_id_created_at', 'deleted_alias', ['user_id', 'created_at'], unique=False, postgresql_concurrently=True)
def downgrade():
with op.get_context().autocommit_block():
op.drop_index('ix_deleted_alias_user_id_created_at', table_name='deleted_alias')
op.drop_column('deleted_alias', 'user_id')

View File

@ -0,0 +1,28 @@
"""Revert user id on deleted alias
Revision ID: bc9aa210efa3
Revises: 4882cc49dde9
Create Date: 2024-11-06 12:44:44.129691
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bc9aa210efa3'
down_revision = '4882cc49dde9'
branch_labels = None
depends_on = None
def upgrade():
with op.get_context().autocommit_block():
op.drop_index('ix_deleted_alias_user_id_created_at', table_name='deleted_alias')
op.drop_column('deleted_alias', 'user_id')
def downgrade():
op.add_column('deleted_alias', sa.Column('user_id', sa.Integer(), server_default=None, nullable=True))
with op.get_context().autocommit_block():
op.create_index('ix_deleted_alias_user_id_created_at', 'deleted_alias', ['user_id', 'created_at'], unique=False, postgresql_concurrently=True)

View File

@ -0,0 +1,30 @@
"""add missing indices on user and mailbox
Revision ID: 842ac670096e
Revises: bc9aa210efa3
Create Date: 2024-11-13 15:55:28.798506
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '842ac670096e'
down_revision = 'bc9aa210efa3'
branch_labels = None
depends_on = None
def upgrade():
with op.get_context().autocommit_block():
op.create_index('ix_mailbox_pgp_finger_print', 'mailbox', ['pgp_finger_print'], unique=False)
op.create_index('ix_users_default_mailbox_id', 'users', ['default_mailbox_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
with op.get_context().autocommit_block():
op.drop_index('ix_users_default_mailbox_id', table_name='users')
op.drop_index('ix_mailbox_pgp_finger_print', table_name='mailbox')

View File

@ -0,0 +1,29 @@
"""add missing indices on email log
Revision ID: 12274da2299f
Revises: 842ac670096e
Create Date: 2024-11-14 10:27:20.371191
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '12274da2299f'
down_revision = '842ac670096e'
branch_labels = None
depends_on = None
def upgrade():
with op.get_context().autocommit_block():
op.create_index('ix_email_log_bounced_mailbox_id', 'email_log', ['bounced_mailbox_id'], unique=False)
op.create_index('ix_email_log_mailbox_id', 'email_log', ['mailbox_id'], unique=False)
def downgrade():
with op.get_context().autocommit_block():
op.drop_index('ix_email_log_mailbox_id', table_name='email_log')
op.drop_index('ix_email_log_bounced_mailbox_id', table_name='email_log')

View File

@ -0,0 +1,102 @@
"""add missing indices for fk constraints
Revision ID: 0f3ee15b0014
Revises: 12274da2299f
Create Date: 2024-11-15 12:29:10.739938
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0f3ee15b0014'
down_revision = '12274da2299f'
branch_labels = None
depends_on = None
def upgrade():
with op.get_context().autocommit_block():
op.create_index('ix_activation_code_user_id', 'activation_code', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_alias_original_owner_id', 'alias', ['original_owner_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_alias_used_on_user_id', 'alias_used_on', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_api_to_cookie_token_api_key_id', 'api_cookie_token', ['api_key_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_api_to_cookie_token_user_id', 'api_cookie_token', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_api_key_code', 'api_key', ['code'], unique=False, postgresql_concurrently=True)
op.create_index('ix_api_key_user_id', 'api_key', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_authorization_code_client_id', 'authorization_code', ['client_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_authorization_code_user_id', 'authorization_code', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_authorized_address_user_id', 'authorized_address', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_auto_create_rule_custom_domain_id', 'auto_create_rule', ['custom_domain_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_batch_import_file_id', 'batch_import', ['file_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_batch_import_user_id', 'batch_import', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_client_icon_id', 'client', ['icon_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_client_referral_id', 'client', ['referral_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_client_user_id', 'client', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_coupon_used_by_user_id', 'coupon', ['used_by_user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_directory_user_id', 'directory', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_domain_deleted_alias_user_id', 'domain_deleted_alias', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_email_log_refused_email_id', 'email_log', ['refused_email_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_fido_user_id', 'fido', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_file_user_id', 'file', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_hibp_notified_alias_user_id', 'hibp_notified_alias', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_mfa_browser_user_id', 'mfa_browser', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_newsletter_user_user_id', 'newsletter_user', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_oauth_token_client_id', 'oauth_token', ['client_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_oauth_token_user_id', 'oauth_token', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_payout_user_id', 'payout', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_phone_reservation_user_id', 'phone_reservation', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_provider_complaint_refused_email_id', 'provider_complaint', ['refused_email_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_provider_complaint_user_id', 'provider_complaint', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_redirect_uri_client_id', 'redirect_uri', ['client_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_referral_user_id', 'referral', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_refused_email_user_id', 'refused_email', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_reset_password_code_user_id', 'reset_password_code', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_sent_alert_user_id', 'sent_alert', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_users_default_alias_custom_domain_id', 'users', ['default_alias_custom_domain_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_users_profile_picture_id', 'users', ['profile_picture_id'], unique=False, postgresql_concurrently=True)
def downgrade():
with op.get_context().autocommit_block():
op.drop_index('ix_users_profile_picture_id', table_name='users')
op.drop_index('ix_users_default_alias_custom_domain_id', table_name='users')
op.drop_index('ix_sent_alert_user_id', table_name='sent_alert')
op.drop_index('ix_reset_password_code_user_id', table_name='reset_password_code')
op.drop_index('ix_refused_email_user_id', table_name='refused_email')
op.drop_index('ix_referral_user_id', table_name='referral')
op.drop_index('ix_redirect_uri_client_id', table_name='redirect_uri')
op.drop_index('ix_provider_complaint_user_id', table_name='provider_complaint')
op.drop_index('ix_provider_complaint_refused_email_id', table_name='provider_complaint')
op.drop_index('ix_phone_reservation_user_id', table_name='phone_reservation')
op.drop_index('ix_payout_user_id', table_name='payout')
op.drop_index('ix_oauth_token_user_id', table_name='oauth_token')
op.drop_index('ix_oauth_token_client_id', table_name='oauth_token')
op.drop_index('ix_newsletter_user_user_id', table_name='newsletter_user')
op.drop_index('ix_mfa_browser_user_id', table_name='mfa_browser')
op.drop_index('ix_hibp_notified_alias_user_id', table_name='hibp_notified_alias')
op.drop_index('ix_file_user_id', table_name='file')
op.drop_index('ix_fido_user_id', table_name='fido')
op.drop_index('ix_email_log_refused_email_id', table_name='email_log')
op.drop_index('ix_domain_deleted_alias_user_id', table_name='domain_deleted_alias')
op.drop_index('ix_directory_user_id', table_name='directory')
op.drop_index('ix_coupon_used_by_user_id', table_name='coupon')
op.drop_index('ix_client_user_id', table_name='client')
op.drop_index('ix_client_referral_id', table_name='client')
op.drop_index('ix_client_icon_id', table_name='client')
op.drop_index('ix_batch_import_user_id', table_name='batch_import')
op.drop_index('ix_batch_import_file_id', table_name='batch_import')
op.drop_index('ix_auto_create_rule_custom_domain_id', table_name='auto_create_rule')
op.drop_index('ix_authorized_address_user_id', table_name='authorized_address')
op.drop_index('ix_authorization_code_user_id', table_name='authorization_code')
op.drop_index('ix_authorization_code_client_id', table_name='authorization_code')
op.drop_index('ix_api_key_user_id', table_name='api_key')
op.drop_index('ix_api_key_code', table_name='api_key')
op.drop_index('ix_api_to_cookie_token_user_id', table_name='api_cookie_token')
op.drop_index('ix_api_to_cookie_token_api_key_id', table_name='api_cookie_token')
op.drop_index('ix_alias_used_on_user_id', table_name='alias_used_on')
op.drop_index('ix_alias_original_owner_id', table_name='alias')
op.drop_index('ix_activation_code_user_id', table_name='activation_code')

View File

@ -0,0 +1,62 @@
#!/usr/bin/env python3
import argparse
import time
import arrow
from sqlalchemy import func
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import UserPlanChanged, EventContent
from app.models import PartnerUser, User
from app.db import Session
parser = argparse.ArgumentParser(
prog="Backfill alias", description="Send lifetime users to proton"
)
parser.add_argument(
"-s", "--start_pu_id", default=0, type=int, help="Initial partner_user_id"
)
parser.add_argument(
"-e", "--end_pu_id", default=0, type=int, help="Last partner_user_id"
)
args = parser.parse_args()
pu_id_start = args.start_pu_id
max_pu_id = args.end_pu_id
if max_pu_id == 0:
max_pu_id = Session.query(func.max(PartnerUser.id)).scalar()
print(f"Checking partner user {pu_id_start} to {max_pu_id}")
step = 1000
done = 0
start_time = time.time()
with_lifetime = 0
for batch_start in range(pu_id_start, max_pu_id, step):
users = (
Session.query(User)
.join(PartnerUser, PartnerUser.user_id == User.id)
.filter(
PartnerUser.id >= batch_start,
PartnerUser.id < batch_start + step,
User.lifetime == True, # noqa :E712
)
).all()
for user in users:
# Just in case the == True cond is wonky
if not user.lifetime:
continue
with_lifetime += 1
event = UserPlanChanged(plan_end_time=arrow.get("2038-01-01").timestamp)
EventDispatcher.send_event(user, EventContent(user_plan_change=event))
Session.flush()
Session.commit()
elapsed = time.time() - start_time
last_batch_id = batch_start + step
time_per_alias = elapsed / (last_batch_id)
remaining = max_pu_id - last_batch_id
time_remaining = remaining / time_per_alias
hours_remaining = time_remaining / 60.0
print(
f"\PartnerUser {batch_start}/{max_pu_id} {with_lifetime} {hours_remaining:.2f} mins remaining"
)
print(f"With SL lifetime {with_lifetime}")

View File

@ -2,10 +2,10 @@
import argparse import argparse
import time import time
import arrow
from sqlalchemy import func from sqlalchemy import func
from app.events.event_dispatcher import EventDispatcher from app.account_linking import send_user_plan_changed_event
from app.events.generated.event_pb2 import UserPlanChanged, EventContent
from app.models import PartnerUser from app.models import PartnerUser
from app.db import Session from app.db import Session
@ -30,6 +30,7 @@ step = 100
updated = 0 updated = 0
start_time = time.time() start_time = time.time()
with_premium = 0 with_premium = 0
with_lifetime = 0
for batch_start in range(pu_id_start, max_pu_id, step): for batch_start in range(pu_id_start, max_pu_id, step):
partner_users = ( partner_users = (
Session.query(PartnerUser).filter( Session.query(PartnerUser).filter(
@ -37,18 +38,12 @@ for batch_start in range(pu_id_start, max_pu_id, step):
) )
).all() ).all()
for partner_user in partner_users: for partner_user in partner_users:
subscription_end = partner_user.user.get_active_subscription_end( subscription_end = send_user_plan_changed_event(partner_user)
include_partner_subscription=False if subscription_end is not None:
) if subscription_end > arrow.get("2038-01-01").timestamp:
end_timestamp = None with_lifetime += 1
if subscription_end: else:
with_premium += 1 with_premium += 1
end_timestamp = subscription_end.timestamp
event = UserPlanChanged(plan_end_time=end_timestamp)
EventDispatcher.send_event(
partner_user.user, EventContent(user_plan_change=event)
)
Session.flush()
updated += 1 updated += 1
Session.commit() Session.commit()
elapsed = time.time() - start_time elapsed = time.time() - start_time
@ -60,4 +55,4 @@ for batch_start in range(pu_id_start, max_pu_id, step):
print( print(
f"\PartnerUser {batch_start}/{max_pu_id} {updated} {hours_remaining:.2f} mins remaining" f"\PartnerUser {batch_start}/{max_pu_id} {updated} {hours_remaining:.2f} mins remaining"
) )
print(f"With SL premium {with_premium}") print(f"With SL premium {with_premium} lifetime {with_lifetime}")

View File

@ -1,279 +1,295 @@
{% extends 'admin/master.html' %} {% extends 'admin/master.html' %}
{% macro show_user(user) -%} {% macro show_user(user) -%}
<h4>User {{ user.email }} with ID {{ user.id }}.</h4> <h4>User {{ user.email }} with ID {{ user.id }}.</h4>
{% set pu = helper.partner_user(user) %} {% set pu = helper.partner_user(user) %}
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th scope="col">User ID</th> <th scope="col">User ID</th>
<th scope="col">Email</th> <th scope="col">Email</th>
<th scope="col">Status</th> <th scope="col">Verified</th>
<th scope="col">Paid</th> <th scope="col">Status</th>
<th>Subscription</th> <th scope="col">Paid</th>
<th>Created At</th> <th scope="col">Premium</th>
<th>Updated At</th> <th>Subscription</th>
<th>Connected with Proton account</th> <th>Created At</th>
</tr> <th>Updated At</th>
</thead> <th>Connected with Proton account</th>
<tbody> </tr>
<tr> </thead>
<td>{{ user.id }}</td> <tbody>
<td><a href="?email={{ user.email }}">{{ user.email }}</a></td> <tr>
{% if user.disabled %} <td>{{ user.id }}</td>
<td>
<a href="?email={{ user.email }}">{{ user.email }}</a>
</td>
{% if user.activated %}
<td class="text-danger">Disabled</td> <td class="text-success">Activated</td>
{% else %} {% else %}
<td class="text-success">Enabled</td> <td class="text-warning">Pending</td>
{% endif %} {% endif %}
<td>{{ "yes" if user.is_paid() else "No" }}</td> {% if user.disabled %}
<td>{{ user.get_active_subscription() }}</td>
<td>{{ user.created_at }}</td>
<td>{{ user.updated_at }}</td>
{% if pu %}
<td><a href="?email={{ pu.partner_email }}">{{ pu.partner_email }}</a></td> <td class="text-danger">Disabled</td>
{% else %} {% else %}
<td>No</td> <td class="text-success">Enabled</td>
{% endif %} {% endif %}
</tr> <td>{{ "yes" if user.is_paid() else "No" }}</td>
</tbody> <td>{{ "yes" if user.is_premium() else "No" }}</td>
</table> <td>{{ user.get_active_subscription() }}</td>
<td>{{ user.created_at }}</td>
<td>{{ user.updated_at }}</td>
{% if pu %}
<td>
<a href="?email={{ pu.partner_email }}">{{ pu.partner_email }}</a>
</td>
{% else %}
<td>No</td>
{% endif %}
</tr>
</tbody>
</table>
{%- endmacro %} {%- endmacro %}
{% macro list_mailboxes(message, mbox_count, mboxes) %} {% macro list_mailboxes(message, mbox_count, mboxes) %}
<h4> <h4>
{{ mbox_count }} {{ message }}. {{ mbox_count }} {{ message }}.
{% if mbox_count>10 %}Showing only the last 10.{% endif %} {% if mbox_count>10 %}Showing only the last 10.{% endif %}
</h4> </h4>
<table class="table"> <table class="table">
<thead> <thead>
<tr>
<th>Mailbox ID</th>
<th>Email</th>
<th>Verified</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{% for mailbox in mboxes %}
<tr> <tr>
<th>Mailbox ID</th> <td>{{ mailbox.id }}</td>
<th>Email</th> <td>
<th>Verified</th> <a href="?email={{ mailbox.email }}">{{ mailbox.email }}</a>
<th>Created At</th> </td>
<td>{{ "Yes" if mailbox.verified else "No" }}</td>
<td>{{ mailbox.created_at }}</td>
</tr> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for mailbox in mboxes %} </table>
<tr>
<td>{{ mailbox.id }}</td>
<td><a href="?email={{ mailbox.email }}">{{ mailbox.email }}</a></td>
<td>{{ "Yes" if mailbox.verified else "No" }}</td>
<td>
{{ mailbox.created_at }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %} {% endmacro %}
{% macro list_alias(alias_count, aliases) %} {% macro list_alias(alias_count, aliases) %}
<h4> <h4>
{{ alias_count }} Aliases found. {{ alias_count }} Aliases found.
{% if alias_count>10 %}Showing only the last 10.{% endif %} {% if alias_count>10 %}Showing only the last 10.{% endif %}
</h4> </h4>
<table class="table"> <table class="table">
<thead> <thead>
<tr>
<th>Alias ID</th>
<th>Email</th>
<th>Enabled</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{% for alias in aliases %}
<tr> <tr>
<th> <td>{{ alias.id }}</td>
Alias ID <td>
</th> <a href="?email={{ alias.email }}">{{ alias.email }}</a>
<th> </td>
Email <td>{{ "Yes" if alias.enabled else "No" }}</td>
</th> <td>{{ alias.created_at }}</td>
<th>
Enabled
</th>
<th>
Created At
</th>
</tr> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for alias in aliases %} </table>
<tr>
<td>{{ alias.id }}</td>
<td><a href="?email={{ alias.email }}">{{ alias.email }}</a></td>
<td>{{ "Yes" if alias.enabled else "No" }}</td>
<td>{{ alias.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %} {% endmacro %}
{% macro show_deleted_alias(deleted_alias) -%} {% macro show_deleted_alias(deleted_alias) -%}
<h4>Deleted Alias {{ deleted_alias.email }} with ID {{ deleted_alias.id }}.</h4> <h4>Deleted Alias {{ deleted_alias.email }} with ID {{ deleted_alias.id }}.</h4>
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th scope="col">Deleted Alias ID</th> <th scope="col">Deleted Alias ID</th>
<th scope="col">Email</th> <th scope="col">Email</th>
<th scope="col">Deleted At</th> <th scope="col">Deleted At</th>
<th scope="col">Reason</th> <th scope="col">Reason</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>{{ deleted_alias.id }}</td> <td>{{ deleted_alias.id }}</td>
<td>{{ deleted_alias.email }}</td> <td>{{ deleted_alias.email }}</td>
<td>{{ deleted_alias.created_at }}</td> <td>{{ deleted_alias.created_at }}</td>
<td>{{ deleted_alias.reason }}</td> <td>{{ deleted_alias.reason }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
{%- endmacro %} {%- endmacro %}
{% macro show_domain_deleted_alias(dom_deleted_alias) -%} {% macro show_domain_deleted_alias(dom_deleted_alias) -%}
<h4> <h4>
Domain Deleted Alias {{ dom_deleted_alias.email }} with ID {{ dom_deleted_alias.id }} for Domain Deleted Alias {{ dom_deleted_alias.email }} with ID {{ dom_deleted_alias.id }} for
domain {{ dom_deleted_alias.domain.domain }} domain {{ dom_deleted_alias.domain.domain }}
</h4> </h4>
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th scope="col">Deleted Alias ID</th> <th scope="col">Deleted Alias ID</th>
<th scope="col">Email</th> <th scope="col">Email</th>
<th scope="col">Domain</th> <th scope="col">Domain</th>
<th scope="col">Domain ID</th> <th scope="col">Domain ID</th>
<th scope="col">Domain owner user ID</th> <th scope="col">Domain owner user ID</th>
<th scope="col">Domain owner user email</th> <th scope="col">Domain owner user email</th>
<th scope="col">Deleted At</th> <th scope="col">Deleted At</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>{{ dom_deleted_alias.id }}</td> <td>{{ dom_deleted_alias.id }}</td>
<td>{{ dom_deleted_alias.email }}</td> <td>{{ dom_deleted_alias.email }}</td>
<td>{{ dom_deleted_alias.domain.domain }}</td> <td>{{ dom_deleted_alias.domain.domain }}</td>
<td>{{ dom_deleted_alias.domain.id }}</td> <td>{{ dom_deleted_alias.domain.id }}</td>
<td>{{ dom_deleted_alias.domain.user_id }}</td> <td>{{ dom_deleted_alias.domain.user_id }}</td>
<td>{{ dom_deleted_alias.created_at }}</td> <td>{{ dom_deleted_alias.created_at }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
{{ show_user(data.domain_deleted_alias.domain.user) }} {{ show_user(data.domain_deleted_alias.domain.user) }}
{%- endmacro %} {%- endmacro %}
{% macro list_alias_audit_log(alias_audit_log) %} {% macro list_alias_audit_log(alias_audit_log) %}
<h4>Alias Audit Log</h4> <h4>Alias Audit Log</h4>
<table class="table"> <table class="table">
<thead> <thead>
<tr>
<th>User ID</th>
<th>Alias ID</th>
<th>Alias Email</th>
<th>Action</th>
<th>Message</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for entry in alias_audit_log %}
<tr> <tr>
<th>User ID</th> <td>{{ entry.user_id }}</td>
<th>Alias ID</th> <td>{{ entry.alias_id }}</td>
<th>Alias Email</th> <td>
<th>Action</th> <a href="?email={{ entry.alias_email }}">{{ entry.alias_email }}</a>
<th>Message</th> </td>
<th>Time</th> <td>{{ entry.action }}</td>
<td>{{ entry.message }}</td>
<td>{{ entry.created_at }}</td>
</tr> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for entry in alias_audit_log %} </table>
<tr>
<td>{{ entry.user_id }}</td>
<td>{{ entry.alias_id }}</td>
<td><a href="?email={{ entry.alias_email }}">{{ entry.alias_email }}</a></td>
<td>{{ entry.action }}</td>
<td>{{ entry.message }}</td>
<td>{{ entry.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %} {% endmacro %}
{% macro list_user_audit_log(user_audit_log) %} {% macro list_user_audit_log(user_audit_log) %}
<h4>User Audit Log</h4> <h4>User Audit Log</h4>
<table class="table"> <table class="table">
<thead> <thead>
<tr>
<th>User email</th>
<th>Action</th>
<th>Message</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for entry in user_audit_log %}
<tr> <tr>
<th>User email</th> <td>
<th>Action</th> <a href="?email={{ entry.user_email }}">{{ entry.user_email }}</a>
<th>Message</th> </td>
<th>Time</th> <td>{{ entry.action }}</td>
<td>{{ entry.message }}</td>
<td>{{ entry.created_at }}</td>
</tr> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for entry in user_audit_log %} </table>
<tr>
<td><a href="?email={{ entry.user_email }}">{{ entry.user_email }}</a></td>
<td>{{ entry.action }}</td>
<td>{{ entry.message }}</td>
<td>{{ entry.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %} {% endmacro %}
{% block body %} {% block body %}
<div class="border border-dark border-2 mt-1 mb-2 p-3">
<form method="get">
<div class="form-group">
<label for="email">Email to search:</label>
<input type="text"
class="form-control"
name="email"
value="{{ email or '' }}" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
{% if data.no_match and email %}
<div class="border border-dark border-2 mt-1 mb-2 p-3 alert alert-warning"
role="alert">No user, alias or mailbox found for {{ email }}</div>
{% endif %}
{% if data.alias %}
<div class="border border-dark border-2 mt-1 mb-2 p-3"> <div class="border border-dark border-2 mt-1 mb-2 p-3">
<form method="get"> <h3 class="mb-3">Found Alias {{ data.alias.email }}</h3>
<div class="form-group"> {{ list_alias(1,[data.alias]) }}
<label for="email">Email to search:</label> {{ list_alias_audit_log(data.alias_audit_log) }}
<input type="text" {{ list_mailboxes("Mailboxes for alias", helper.alias_mailbox_count(data.alias) , helper.alias_mailboxes(data.alias)) }}
class="form-control" {{ show_user(data.alias.user) }}
name="email"
value="{{ email or '' }}"/>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div> </div>
{% if data.no_match and email %} {% endif %}
<div class="border border-dark border-2 mt-1 mb-2 p-3 alert alert-warning" {% if data.user %}
role="alert">No user, alias or mailbox found for {{ email }}</div>
{% endif %}
{% if data.alias %} <div class="border border-dark border-2 mt-1 mb-2 p-3">
<div class="border border-dark border-2 mt-1 mb-2 p-3"> <h3 class="mb-3">Found User {{ data.user.email }}</h3>
<h3 class="mb-3">Found Alias {{ data.alias.email }}</h3> {{ show_user(data.user) }}
{{ list_alias(1,[data.alias]) }} {{ list_mailboxes("Mailboxes for user", helper.mailbox_count(data.user) , helper.mailbox_list(data.user) ) }}
{{ list_alias_audit_log(data.alias_audit_log) }} {{ list_alias(helper.alias_count(data.user) ,helper.alias_list(data.user)) }}
{{ list_mailboxes("Mailboxes for alias", helper.alias_mailbox_count(data.alias), helper.alias_mailboxes(data.alias)) }} </div>
{{ show_user(data.alias.user) }} {% endif %}
</div> {% if data.user_audit_log %}
{% endif %}
{% if data.user %} <div class="border border-dark border-2 mt-1 mb-2 p-3">
<div class="border border-dark border-2 mt-1 mb-2 p-3"> <h3 class="mb-3">Audit log entries for user {{ data.query }}</h3>
<h3 class="mb-3">Found User {{ data.user.email }}</h3> {{ list_user_audit_log(data.user_audit_log) }}
{{ show_user(data.user) }} </div>
{{ list_mailboxes("Mailboxes for user", helper.mailbox_count(data.user) , helper.mailbox_list(data.user) ) }} {% endif %}
{{ list_alias(helper.alias_count(data.user) ,helper.alias_list(data.user)) }} {% if data.mailbox_count > 10 %}
</div>
{% endif %}
{% if data.user_audit_log %}
<div class="border border-dark border-2 mt-1 mb-2 p-3">
<h3 class="mb-3">Audit log entries for user {{ data.query }}</h3>
{{ list_user_audit_log(data.user_audit_log) }}
</div>
{% endif %}
{% if data.mailbox_count > 10 %}
<h3>Found more than 10 mailboxes for {{ email }}. Showing the last 10</h3>
{% elif data.mailbox_count > 0 %}
<h3>Found {{ data.mailbox_count }} mailbox(es) for {{ email }}</h3>
{% endif %}
{% for mailbox in data.mailbox %}
<div class="border border-dark mt-1 mb-2 p-3"> <h3>Found more than 10 mailboxes for {{ email }}. Showing the last 10</h3>
<h3 class="mb-3">Found Mailbox {{ mailbox.email }}</h3> {% elif data.mailbox_count > 0 %}
{{ list_mailboxes("Mailbox found", 1, [mailbox]) }} <h3>Found {{ data.mailbox_count }} mailbox(es) for {{ email }}</h3>
{{ show_user(mailbox.user) }} {% endif %}
</div> {% for mailbox in data.mailbox %}
{% endfor %}
{% if data.deleted_alias %}
<div class="border border-dark mt-1 mb-2 p-3"> <div class="border border-dark mt-1 mb-2 p-3">
<h3 class="mb-3">Found DeletedAlias {{ data.deleted_alias.email }}</h3> <h3 class="mb-3">Found Mailbox {{ mailbox.email }}</h3>
{{ show_deleted_alias(data.deleted_alias) }} {{ list_mailboxes("Mailbox found", 1, [mailbox]) }}
{{ list_alias_audit_log(data.deleted_alias_audit_log) }} {{ show_user(mailbox.user) }}
</div> </div>
{% endif %} {% endfor %}
{% if data.domain_deleted_alias %} {% if data.deleted_alias %}
<div class="border border-dark mt-1 mb-2 p-3"> <div class="border border-dark mt-1 mb-2 p-3">
<h3 class="mb-3">Found DomainDeletedAlias {{ data.domain_deleted_alias.email }}</h3> <h3 class="mb-3">Found DeletedAlias {{ data.deleted_alias.email }}</h3>
{{ show_domain_deleted_alias(data.domain_deleted_alias) }} {{ show_deleted_alias(data.deleted_alias) }}
{{ list_alias_audit_log(data.domain_deleted_alias_audit_log) }} {{ list_alias_audit_log(data.deleted_alias_audit_log) }}
</div> </div>
{% endif %} {% endif %}
{% if data.domain_deleted_alias %}
<div class="border border-dark mt-1 mb-2 p-3">
<h3 class="mb-3">Found DomainDeletedAlias {{ data.domain_deleted_alias.email }}</h3>
{{ show_domain_deleted_alias(data.domain_deleted_alias) }}
{{ list_alias_audit_log(data.domain_deleted_alias_audit_log) }}
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -43,7 +43,7 @@
You can change the plan at any moment. You can change the plan at any moment.
<br /> <br />
Please note that the new billing cycle starts instantly Please note that the new billing cycle starts instantly
i.e. you will be charged <b>immediately</b> the annual fee ($30) when switching from monthly plan or vice-versa i.e. you will be charged <b>immediately</b> the annual fee ($36) when switching from monthly plan or vice-versa
<b>without pro rata computation </b>. <b>without pro rata computation </b>.
<br /> <br />
To change the plan you can also cancel the current one and subscribe a new one <b>by the end</b> of this plan. To change the plan you can also cancel the current one and subscribe a new one <b>by the end</b> of this plan.

View File

@ -94,4 +94,3 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -91,7 +91,6 @@
<br /> <br />
Some domain registrars (Namecheap, CloudFlare, etc) might also use <em>@</em> for the root domain. Some domain registrars (Namecheap, CloudFlare, etc) might also use <em>@</em> for the root domain.
</div> </div>
{% for record in expected_mx_records %} {% for record in expected_mx_records %}
<div class="mb-3 p-3 dns-record"> <div class="mb-3 p-3 dns-record">
@ -108,7 +107,6 @@
data-clipboard-text="{{ record.domain }}">{{ record.domain }}</em> data-clipboard-text="{{ record.domain }}">{{ record.domain }}</em>
</div> </div>
{% endfor %} {% endfor %}
<form method="post" action="#mx-form"> <form method="post" action="#mx-form">
{{ csrf_form.csrf_token }} {{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="check-mx"> <input type="hidden" name="form-name" value="check-mx">

View File

@ -22,7 +22,8 @@
<p>Alternatively you can use your Proton credentials to ensure it's you.</p> <p>Alternatively you can use your Proton credentials to ensure it's you.</p>
</div> </div>
<a class="btn btn-primary btn-block mt-2 proton-button" <a class="btn btn-primary btn-block mt-2 proton-button"
href="{{ url_for('auth.proton_login', next=next) }}" style="max-width: 400px"> href="{{ url_for('auth.proton_login', next=next) }}"
style="max-width: 400px">
<img class="mr-2" src="/static/images/proton.svg" /> <img class="mr-2" src="/static/images/proton.svg" />
Authenticate with Proton Authenticate with Proton
</a> </a>
@ -38,4 +39,4 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -11,7 +11,7 @@
<div> <div>
<a class="buy-with-crypto" <a class="buy-with-crypto"
data-custom="{{ current_user.id }}" data-custom="{{ current_user.id }}"
href="{{ coinbase_url }}">Extend for 1 year - $30</a> href="{{ coinbase_url }}">Extend for 1 year - $36</a>
<script src="https://commerce.coinbase.com/v1/checkout.js?version=201807"></script> <script src="https://commerce.coinbase.com/v1/checkout.js?version=201807"></script>
</div> </div>
<div class="mt-2"> <div class="mt-2">

View File

@ -77,6 +77,11 @@
<div class="text-center mx-md-auto mb-8 mt-6"> <div class="text-center mx-md-auto mb-8 mt-6">
<h1>Upgrade to unlock premium features</h1> <h1>Upgrade to unlock premium features</h1>
</div> </div>
<div class="alert alert-info">
<span class="badge badge-success">new</span> SimpleLogin Premium now includes Proton Pass premium features.
<a href="https://simplelogin.io/blog/sl-premium-including-pass-plus/"
target="_blank">Learn more ↗</a>
</div>
{% if manual_sub %} {% if manual_sub %}
<div class="alert alert-info mt-0 mb-6"> <div class="alert alert-info mt-0 mb-6">
@ -306,7 +311,7 @@
<div class="card-body"> <div class="card-body">
<div class="text-center"> <div class="text-center">
<div class="h3">SimpleLogin Premium</div> <div class="h3">SimpleLogin Premium</div>
<div class="h3 my-3">$30 / year</div> <div class="h3 my-3">$36 / year</div>
<div class="text-center mt-4 mb-6"> <div class="text-center mt-4 mb-6">
<button class="btn btn-primary btn-lg w-100" <button class="btn btn-primary btn-lg w-100"
onclick="upgradePaddle({{ PADDLE_YEARLY_PRODUCT_ID }})">Upgrade to Premium</button> onclick="upgradePaddle({{ PADDLE_YEARLY_PRODUCT_ID }})">Upgrade to Premium</button>
@ -471,7 +476,7 @@
rel="noopener noreferrer"> rel="noopener noreferrer">
Upgrade to Premium - cryptocurrency Upgrade to Premium - cryptocurrency
<br /> <br />
$30 / year $36 / year
<i class="fe fe-external-link"></i> <i class="fe fe-external-link"></i>
</a> </a>
</div> </div>

View File

@ -511,6 +511,19 @@ def test_create_contact_route_invalid_alias(flask_client):
assert r.status_code == 403 assert r.status_code == 403
def test_create_contact_route_non_existing_alias(flask_client):
user, api_key = get_new_user_and_api_key()
Session.commit()
r = flask_client.post(
url_for("api.create_contact_route", alias_id=99999999),
headers={"Authentication": api_key.code},
json={"contact": "First Last <first@example.com>"},
)
assert r.status_code == 403
def test_create_contact_route_free_users(flask_client): def test_create_contact_route_free_users(flask_client):
user, api_key = get_new_user_and_api_key() user, api_key = get_new_user_and_api_key()

View File

@ -5,7 +5,7 @@ from app.models import Mailbox
from tests.utils import login from tests.utils import login
def test_create_mailbox(flask_client): def test_create_mailbox_valid(flask_client):
login(flask_client) login(flask_client)
r = flask_client.post( r = flask_client.post(
@ -21,10 +21,34 @@ def test_create_mailbox(flask_client):
assert r.json["default"] is False assert r.json["default"] is False
assert r.json["nb_alias"] == 0 assert r.json["nb_alias"] == 0
# invalid email address
def test_create_mailbox_invalid_email(flask_client):
login(flask_client)
r = flask_client.post( r = flask_client.post(
"/api/mailboxes", "/api/mailboxes",
json={"email": "gmail.com"}, json={"email": "gmail.com"}, # not an email address
)
assert r.status_code == 400
assert r.json == {"error": "Invalid email"}
def test_create_mailbox_empty_payload(flask_client):
login(flask_client)
r = flask_client.post(
"/api/mailboxes",
json={},
)
assert r.status_code == 400
assert r.json == {"error": "Invalid email"}
def test_create_mailbox_empty_email(flask_client):
login(flask_client)
r = flask_client.post(
"/api/mailboxes",
json={"email": ""},
) )
assert r.status_code == 400 assert r.status_code == 400

View File

@ -0,0 +1,40 @@
import arrow
from app import config
from app.events.generated.event_pb2 import EventContent, AliasDeleted
from app.jobs.send_event_job import SendEventToWebhookJob
from app.models import PartnerUser
from app.proton.utils import get_proton_partner
from events.event_sink import ConsoleEventSink
from tests.utils import create_new_user, random_token
def test_serialize_and_deserialize_job():
user = create_new_user()
alias_id = 34
alias_email = "a@b.c"
event = EventContent(alias_deleted=AliasDeleted(id=alias_id, email=alias_email))
run_at = arrow.now().shift(hours=10)
db_job = SendEventToWebhookJob(user, event).store_job_in_db(run_at=run_at)
assert db_job.run_at == run_at
assert db_job.name == config.JOB_SEND_EVENT_TO_WEBHOOK
job = SendEventToWebhookJob.create_from_job(db_job)
assert job._user.id == user.id
assert job._event.alias_deleted.id == alias_id
assert job._event.alias_deleted.email == alias_email
def test_send_event_to_webhook():
user = create_new_user()
PartnerUser.create(
user_id=user.id,
partner_id=get_proton_partner().id,
external_user_id=random_token(10),
flush=True,
)
alias_id = 34
alias_email = "a@b.c"
event = EventContent(alias_deleted=AliasDeleted(id=alias_id, email=alias_email))
job = SendEventToWebhookJob(user, event)
sink = ConsoleEventSink()
assert job.run(sink)

View File

@ -1,5 +1,5 @@
from app.db import Session from app.db import Session
from app.models import Alias, Mailbox, AliasMailbox, User from app.models import Alias, Mailbox, AliasMailbox, User, CustomDomain
from tests.utils import create_new_user, random_email from tests.utils import create_new_user, random_email
@ -29,3 +29,23 @@ def test_alias_create_from_partner_flags_also_the_user():
flush=True, flush=True,
) )
assert alias.user.flags & User.FLAG_CREATED_ALIAS_FROM_PARTNER > 0 assert alias.user.flags & User.FLAG_CREATED_ALIAS_FROM_PARTNER > 0
def test_alias_create_from_partner_domain_flags_the_alias():
user = create_new_user()
domain = CustomDomain.create(
domain=random_email(),
verified=True,
user_id=user.id,
partner_id=1,
)
Session.flush()
email = random_email()
alias = Alias.create(
user_id=user.id,
email=email,
mailbox_id=user.default_mailbox_id,
custom_domain_id=domain.id,
flush=True,
)
assert alias.flags & Alias.FLAG_PARTNER_CREATED > 0

View File

@ -25,15 +25,17 @@ class MockProtonClient(ProtonClient):
return self.user return self.user
def check_initial_sync_job(user: User): def check_initial_sync_job(user: User, expected: bool):
found = False
for job in Job.yield_per_query(10).filter_by( for job in Job.yield_per_query(10).filter_by(
name=config.JOB_SEND_ALIAS_CREATION_EVENTS, name=config.JOB_SEND_ALIAS_CREATION_EVENTS,
state=JobState.ready.value, state=JobState.ready.value,
): ):
if job.payload.get("user_id") == user.id: if job.payload.get("user_id") == user.id:
found = True
Job.delete(job.id) Job.delete(job.id)
return break
assert False assert expected == found
def test_proton_callback_handler_unexistant_sl_user(): def test_proton_callback_handler_unexistant_sl_user():
@ -69,10 +71,9 @@ def test_proton_callback_handler_unexistant_sl_user():
) )
assert partner_user is not None assert partner_user is not None
assert partner_user.external_user_id == external_id assert partner_user.external_user_id == external_id
check_initial_sync_job(res.user)
def test_proton_callback_handler_existant_sl_user(): def test_proton_callback_handler_existing_sl_user():
email = random_email() email = random_email()
sl_user = User.create(email, commit=True) sl_user = User.create(email, commit=True)
@ -98,7 +99,43 @@ def test_proton_callback_handler_existant_sl_user():
sa = PartnerUser.get_by(user_id=sl_user.id, partner_id=get_proton_partner().id) sa = PartnerUser.get_by(user_id=sl_user.id, partner_id=get_proton_partner().id)
assert sa is not None assert sa is not None
assert sa.partner_email == user.email assert sa.partner_email == user.email
check_initial_sync_job(res.user) check_initial_sync_job(res.user, True)
def test_proton_callback_handler_linked_sl_user():
email = random_email()
external_id = random_string()
sl_user = User.create(email, commit=True)
PartnerUser.create(
user_id=sl_user.id,
partner_id=get_proton_partner().id,
external_user_id=external_id,
partner_email=email,
commit=True,
)
user = UserInformation(
email=email,
name=random_string(),
id=external_id,
plan=SLPlan(type=SLPlanType.Premium, expiration=Arrow.utcnow().shift(hours=2)),
)
handler = ProtonCallbackHandler(MockProtonClient(user=user))
res = handler.handle_login(get_proton_partner())
assert res.user is not None
assert res.user.id == sl_user.id
# Ensure the user is not marked as created from partner
assert User.FLAG_CREATED_FROM_PARTNER != (
res.user.flags & User.FLAG_CREATED_FROM_PARTNER
)
assert res.user.notification is True
assert res.user.trial_end is not None
sa = PartnerUser.get_by(user_id=sl_user.id, partner_id=get_proton_partner().id)
assert sa is not None
assert sa.partner_email == user.email
check_initial_sync_job(res.user, False)
def test_proton_callback_handler_none_user_login(): def test_proton_callback_handler_none_user_login():

View File

@ -144,6 +144,21 @@ def test_login_case_from_web():
assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value
def test_new_user_strategy_create_missing_link():
email = random_email()
user = User.create(email, commit=True)
nus = NewUserStrategy(
link_request=random_link_request(
email=user.email, external_user_id=random_string(), from_partner=False
),
user=None,
partner=get_proton_partner(),
)
result = nus.create_missing_link(user.email)
assert result.user.id == user.id
assert result.strategy == ExistingUnlinkedUserStrategy.__name__
def test_get_strategy_existing_sl_user(): def test_get_strategy_existing_sl_user():
email = random_email() email = random_email()
user = User.create(email, commit=True) user = User.create(email, commit=True)

View File

@ -286,6 +286,15 @@ def test_verify_other_users_mailbox():
mailbox_utils.verify_mailbox_code(user, mailbox.id, "9999999") mailbox_utils.verify_mailbox_code(user, mailbox.id, "9999999")
def test_verify_other_users_already_verified_mailbox():
other = create_new_user()
mailbox = Mailbox.create(
user_id=other.id, email=random_email(), verified=True, commit=True
)
with pytest.raises(mailbox_utils.MailboxError):
mailbox_utils.verify_mailbox_code(user, mailbox.id, "9999999")
@mail_sender.store_emails_test_decorator @mail_sender.store_emails_test_decorator
def test_verify_fail(): def test_verify_fail():
output = mailbox_utils.create_mailbox(user, random_email()) output = mailbox_utils.create_mailbox(user, random_email())
@ -305,10 +314,13 @@ def test_verify_too_may():
output = mailbox_utils.create_mailbox(user, random_email()) output = mailbox_utils.create_mailbox(user, random_email())
output.activation.tries = mailbox_utils.MAX_ACTIVATION_TRIES output.activation.tries = mailbox_utils.MAX_ACTIVATION_TRIES
Session.commit() Session.commit()
with pytest.raises(mailbox_utils.CannotVerifyError): try:
mailbox_utils.verify_mailbox_code( mailbox_utils.verify_mailbox_code(
user, output.mailbox.id, output.activation.code user, output.mailbox.id, output.activation.code
) )
assert False
except mailbox_utils.CannotVerifyError as e:
assert e.deleted_activation_code
@mail_sender.store_emails_test_decorator @mail_sender.store_emails_test_decorator