Compare commits

...

4 Commits

Author SHA1 Message Date
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
33 changed files with 687 additions and 324 deletions

View File

@ -84,7 +84,7 @@ For email gurus, we have chosen 1024 key length instead of 2048 for DNS simplici
### 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

View File

@ -7,8 +7,4 @@ If you want be up to date on security patches, make sure your SimpleLogin image
## Reporting a Vulnerability
If you've found a security vulnerability, you can disclose it responsibly by sending a summary to security@simplelogin.io.
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.
If you want to report a vulnerability, please take a look at our bug bounty program at https://proton.me/security/bug-bounty.

View File

@ -3,12 +3,16 @@ from dataclasses import dataclass
from enum import Enum
from typing import Optional
import arrow
from arrow import Arrow
from newrelic import agent
from psycopg2.errors import UniqueViolation
from sqlalchemy import or_
from app.db import Session
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.utils import sanitize_email, canonicalize_email
from app.errors import (
@ -54,6 +58,21 @@ class LinkResult:
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):
sub = PartnerSubscription.get_by(partner_user_id=partner_user.id)
if plan.type == SLPlanType.Free:
@ -88,6 +107,8 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
action=UserAuditLogAction.SubscriptionExtended,
message="Extended partner subscription",
)
Session.flush()
send_user_plan_changed_event(partner_user)
Session.commit()
@ -140,15 +161,55 @@ class ClientMergeStrategy(ABC):
class NewUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult:
# Will create a new SL User with a random password
canonical_email = canonicalize_email(self.link_request.email)
new_user = User.create(
email=canonical_email,
name=self.link_request.name,
password=random_string(20),
activated=True,
from_partner=self.link_request.from_partner,
try:
# Will create a new SL User with a random password
new_user = User.create(
email=canonical_email,
name=self.link_request.name,
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(
user=new_user,
partner_id=self.partner.id,
@ -162,17 +223,7 @@ class NewUserStrategy(ClientMergeStrategy):
partner_user,
self.link_request.plan,
)
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__,
)
return partner_user
class ExistingUnlinkedUserStrategy(ClientMergeStrategy):

View File

@ -58,7 +58,7 @@ def verify_prefix_suffix(
# 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):
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
LOG.i("wrong alias suffix %s, user %s", alias_suffix, user)
return False
# SimpleLogin domain case:
@ -75,17 +75,17 @@ def verify_prefix_suffix(
and not config.DISABLE_ALIAS_SUFFIX
):
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
else:
if alias_domain not in user_custom_domains:
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
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 True

View File

@ -153,7 +153,8 @@ def new_custom_alias_v3():
if not isinstance(data, dict):
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 = signed_suffix.strip()

View File

@ -10,6 +10,7 @@ from app.events.auth_event import LoginEvent
from app.extensions import limiter
from app.log import LOG
from app.models import User
from app.pw_models import PasswordOracle
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)
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
g.deduct_limit = True
form.password.data = None

View File

@ -91,6 +91,7 @@ def create_contact(
alias_id = alias.id
try:
flags = Contact.FLAG_PARTNER_CREATED if from_partner else 0
is_invalid_email = email == ""
contact = Contact.create(
user_id=alias.user_id,
alias_id=alias.id,
@ -100,9 +101,10 @@ def create_contact(
mail_from=mail_from,
automatic_created=automatic_created,
flags=flags,
invalid_email=email == "",
invalid_email=is_invalid_email,
commit=True,
)
contact_id = contact.id
if automatic_created:
trail = ". Automatically created"
else:
@ -110,11 +112,11 @@ def create_contact(
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.CreateContact,
message=f"Created contact {contact.id} ({contact.email}){trail}",
message=f"Created contact {contact_id} ({email}){trail}",
commit=True,
)
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:

View File

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

View File

@ -227,7 +227,10 @@ def alias_contact_manager(alias_id):
page = 0
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 ""

View File

@ -71,7 +71,10 @@ def index():
page = 0
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
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_login import login_required, current_user
from flask_wtf import FlaskForm
@ -7,6 +8,8 @@ from app.config import ADMIN_EMAIL
from app.dashboard.base import dashboard_bp
from app.db import Session
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
@ -40,6 +43,14 @@ def lifetime_licence():
current_user.lifetime_coupon_id = coupon.id
if coupon.paid:
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()
# notify admin

View File

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

View File

@ -1,6 +1,5 @@
import dataclasses
import secrets
import random
from enum import Enum
from typing import Optional
import arrow
@ -233,7 +232,7 @@ def generate_activation_code(
if config.MAILBOX_VERIFICATION_OVERRIDE_CODE:
code = config.MAILBOX_VERIFICATION_OVERRIDE_CODE
else:
code = "{:06d}".format(random.randint(1, 999999))
code = "{:06d}".format(secrets.randbelow(1000000))[:6]
else:
code = secrets.token_urlsafe(16)
return MailboxActivation.create(

View File

@ -565,6 +565,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
"ix_users_activated_trial_end_lifetime", activated, trial_end, lifetime
),
sa.Index("ix_users_delete_on", delete_on),
sa.Index("ix_users_default_mailbox_id", default_mailbox_id),
)
@property
@ -1666,6 +1667,11 @@ class Alias(Base, ModelMixin):
custom_domain = Alias.get_custom_domain(email)
if custom_domain:
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)
DailyMetric.get_or_create_today_metric().nb_alias += 1
@ -2069,7 +2075,11 @@ class Contact(Base, ModelMixin):
class EmailLog(Base, ModelMixin):
__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"),
)
user_id = sa.Column(
sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True
@ -2747,7 +2757,10 @@ class Mailbox(Base, ModelMixin):
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])

View File

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

View File

@ -286,8 +286,16 @@ def notify_manual_sub_end():
def poll_apple_subscription():
"""Poll Apple API to update AppleSubscription"""
# todo: only near the end of the subscription
for apple_sub in AppleSubscription.all():
for apple_sub in (
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:
LOG.d("Ignore %s", apple_sub)
continue
@ -900,6 +908,24 @@ def check_mailbox_valid_pgp_keys():
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")
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)
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.
@ -990,11 +1016,16 @@ async def _hibp_check(api_key, queue):
if not alias:
continue
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
alias.hibp_last_check = arrow.utcnow()
Session.commit()
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)

View File

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

View File

@ -177,7 +177,9 @@ from init_app import load_pgp_public_keys
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
"""
@ -208,6 +210,8 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
automatic_created=True,
from_partner=False,
)
if contact_result.error:
LOG.w(f"Error creating contact: {contact_result.error.value}")
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():
LOG.w(f"User {user} has been soft deleted")
return False, status.E502
return [(False, status.E502)]
if not user.can_send_or_receive():
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])
LOG.d("Create or get contact for from_header:%s", from_header)
contact = get_or_create_contact(from_header, envelope.mail_from, alias)
if not contact:
return [(False, status.E504)]
alias = (
contact.alias
) # In case the Session was closed in the get_or_create we re-fetch the alias

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,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 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.account_linking import send_user_plan_changed_event
from app.models import PartnerUser
from app.db import Session
@ -30,6 +30,7 @@ step = 100
updated = 0
start_time = time.time()
with_premium = 0
with_lifetime = 0
for batch_start in range(pu_id_start, max_pu_id, step):
partner_users = (
Session.query(PartnerUser).filter(
@ -37,18 +38,12 @@ for batch_start in range(pu_id_start, max_pu_id, step):
)
).all()
for partner_user in partner_users:
subscription_end = partner_user.user.get_active_subscription_end(
include_partner_subscription=False
)
end_timestamp = None
if subscription_end:
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()
subscription_end = send_user_plan_changed_event(partner_user)
if subscription_end is not None:
if subscription_end > arrow.get("2038-01-01").timestamp:
with_lifetime += 1
else:
with_premium += 1
updated += 1
Session.commit()
elapsed = time.time() - start_time
@ -60,4 +55,4 @@ for batch_start in range(pu_id_start, max_pu_id, step):
print(
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,284 +1,295 @@
{% extends 'admin/master.html' %}
{% macro show_user(user) -%}
<h4>User {{ user.email }} with ID {{ user.id }}.</h4>
{% set pu = helper.partner_user(user) %}
<table class="table">
<thead>
<tr>
<th scope="col">User ID</th>
<th scope="col">Email</th>
<th scope="col">Verified</th>
<th scope="col">Status</th>
<th scope="col">Paid</th>
<th>Subscription</th>
<th>Created At</th>
<th>Updated At</th>
<th>Connected with Proton account</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ user.id }}</td>
<td><a href="?email={{ user.email }}">{{ user.email }}</a></td>
{% if user.activated %}
<td class="text-success">Activated</td>
{% else %}
<td class="text-warning">Pending</td>
{% endif %}
{% if user.disabled %}
<td class="text-danger">Disabled</td>
{% else %}
<td class="text-success">Enabled</td>
{% endif %}
<td>{{ "yes" if user.is_paid() else "No" }}</td>
<td>{{ user.get_active_subscription() }}</td>
<td>{{ user.created_at }}</td>
<td>{{ user.updated_at }}</td>
{% if pu %}
<h4>User {{ user.email }} with ID {{ user.id }}.</h4>
{% set pu = helper.partner_user(user) %}
<table class="table">
<thead>
<tr>
<th scope="col">User ID</th>
<th scope="col">Email</th>
<th scope="col">Verified</th>
<th scope="col">Status</th>
<th scope="col">Paid</th>
<th scope="col">Premium</th>
<th>Subscription</th>
<th>Created At</th>
<th>Updated At</th>
<th>Connected with Proton account</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ user.id }}</td>
<td>
<a href="?email={{ user.email }}">{{ user.email }}</a>
</td>
{% if user.activated %}
<td><a href="?email={{ pu.partner_email }}">{{ pu.partner_email }}</a></td>
{% else %}
<td>No</td>
{% endif %}
</tr>
</tbody>
</table>
<td class="text-success">Activated</td>
{% else %}
<td class="text-warning">Pending</td>
{% endif %}
{% if user.disabled %}
<td class="text-danger">Disabled</td>
{% else %}
<td class="text-success">Enabled</td>
{% endif %}
<td>{{ "yes" if user.is_paid() else "No" }}</td>
<td>{{ "yes" if user.is_premium() else "No" }}</td>
<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 %}
{% macro list_mailboxes(message, mbox_count, mboxes) %}
<h4>
{{ mbox_count }} {{ message }}.
{% if mbox_count>10 %}Showing only the last 10.{% endif %}
</h4>
<table class="table">
<thead>
<h4>
{{ mbox_count }} {{ message }}.
{% if mbox_count>10 %}Showing only the last 10.{% endif %}
</h4>
<table class="table">
<thead>
<tr>
<th>Mailbox ID</th>
<th>Email</th>
<th>Verified</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{% for mailbox in mboxes %}
<tr>
<th>Mailbox ID</th>
<th>Email</th>
<th>Verified</th>
<th>Created At</th>
<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>
</thead>
<tbody>
{% for mailbox in mboxes %}
<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>
{% endfor %}
</tbody>
</table>
{% endmacro %}
{% macro list_alias(alias_count, aliases) %}
<h4>
{{ alias_count }} Aliases found.
{% if alias_count>10 %}Showing only the last 10.{% endif %}
</h4>
<table class="table">
<thead>
<h4>
{{ alias_count }} Aliases found.
{% if alias_count>10 %}Showing only the last 10.{% endif %}
</h4>
<table class="table">
<thead>
<tr>
<th>Alias ID</th>
<th>Email</th>
<th>Enabled</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{% for alias in aliases %}
<tr>
<th>
Alias ID
</th>
<th>
Email
</th>
<th>
Enabled
</th>
<th>
Created At
</th>
<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>
</thead>
<tbody>
{% for alias in aliases %}
<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>
{% endfor %}
</tbody>
</table>
{% endmacro %}
{% macro show_deleted_alias(deleted_alias) -%}
<h4>Deleted Alias {{ deleted_alias.email }} with ID {{ deleted_alias.id }}.</h4>
<table class="table">
<thead>
<tr>
<th scope="col">Deleted Alias ID</th>
<th scope="col">Email</th>
<th scope="col">Deleted At</th>
<th scope="col">Reason</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ deleted_alias.id }}</td>
<td>{{ deleted_alias.email }}</td>
<td>{{ deleted_alias.created_at }}</td>
<td>{{ deleted_alias.reason }}</td>
</tr>
</tbody>
</table>
<h4>Deleted Alias {{ deleted_alias.email }} with ID {{ deleted_alias.id }}.</h4>
<table class="table">
<thead>
<tr>
<th scope="col">Deleted Alias ID</th>
<th scope="col">Email</th>
<th scope="col">Deleted At</th>
<th scope="col">Reason</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ deleted_alias.id }}</td>
<td>{{ deleted_alias.email }}</td>
<td>{{ deleted_alias.created_at }}</td>
<td>{{ deleted_alias.reason }}</td>
</tr>
</tbody>
</table>
{%- endmacro %}
{% macro show_domain_deleted_alias(dom_deleted_alias) -%}
<h4>
Domain Deleted Alias {{ dom_deleted_alias.email }} with ID {{ dom_deleted_alias.id }} for
domain {{ dom_deleted_alias.domain.domain }}
</h4>
<table class="table">
<thead>
<tr>
<th scope="col">Deleted Alias ID</th>
<th scope="col">Email</th>
<th scope="col">Domain</th>
<th scope="col">Domain ID</th>
<th scope="col">Domain owner user ID</th>
<th scope="col">Domain owner user email</th>
<th scope="col">Deleted At</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ dom_deleted_alias.id }}</td>
<td>{{ dom_deleted_alias.email }}</td>
<td>{{ dom_deleted_alias.domain.domain }}</td>
<td>{{ dom_deleted_alias.domain.id }}</td>
<td>{{ dom_deleted_alias.domain.user_id }}</td>
<td>{{ dom_deleted_alias.created_at }}</td>
</tr>
</tbody>
</table>
{{ show_user(data.domain_deleted_alias.domain.user) }}
<h4>
Domain Deleted Alias {{ dom_deleted_alias.email }} with ID {{ dom_deleted_alias.id }} for
domain {{ dom_deleted_alias.domain.domain }}
</h4>
<table class="table">
<thead>
<tr>
<th scope="col">Deleted Alias ID</th>
<th scope="col">Email</th>
<th scope="col">Domain</th>
<th scope="col">Domain ID</th>
<th scope="col">Domain owner user ID</th>
<th scope="col">Domain owner user email</th>
<th scope="col">Deleted At</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ dom_deleted_alias.id }}</td>
<td>{{ dom_deleted_alias.email }}</td>
<td>{{ dom_deleted_alias.domain.domain }}</td>
<td>{{ dom_deleted_alias.domain.id }}</td>
<td>{{ dom_deleted_alias.domain.user_id }}</td>
<td>{{ dom_deleted_alias.created_at }}</td>
</tr>
</tbody>
</table>
{{ show_user(data.domain_deleted_alias.domain.user) }}
{%- endmacro %}
{% macro list_alias_audit_log(alias_audit_log) %}
<h4>Alias Audit Log</h4>
<table class="table">
<thead>
<h4>Alias Audit Log</h4>
<table class="table">
<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>
<th>User ID</th>
<th>Alias ID</th>
<th>Alias Email</th>
<th>Action</th>
<th>Message</th>
<th>Time</th>
<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>
</thead>
<tbody>
{% for entry in alias_audit_log %}
<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>
{% endfor %}
</tbody>
</table>
{% endmacro %}
{% macro list_user_audit_log(user_audit_log) %}
<h4>User Audit Log</h4>
<table class="table">
<thead>
<h4>User Audit Log</h4>
<table class="table">
<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>
<th>User email</th>
<th>Action</th>
<th>Message</th>
<th>Time</th>
<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>
</thead>
<tbody>
{% for entry in user_audit_log %}
<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>
{% endfor %}
</tbody>
</table>
{% endmacro %}
{% 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">
<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>
<h3 class="mb-3">Found Alias {{ data.alias.email }}</h3>
{{ list_alias(1,[data.alias]) }}
{{ list_alias_audit_log(data.alias_audit_log) }}
{{ list_mailboxes("Mailboxes for alias", helper.alias_mailbox_count(data.alias) , helper.alias_mailboxes(data.alias)) }}
{{ show_user(data.alias.user) }}
</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 %}
{% endif %}
{% if data.user %}
{% if data.alias %}
<div class="border border-dark border-2 mt-1 mb-2 p-3">
<h3 class="mb-3">Found Alias {{ data.alias.email }}</h3>
{{ list_alias(1,[data.alias]) }}
{{ list_alias_audit_log(data.alias_audit_log) }}
{{ list_mailboxes("Mailboxes for alias", helper.alias_mailbox_count(data.alias), helper.alias_mailboxes(data.alias)) }}
{{ show_user(data.alias.user) }}
</div>
{% endif %}
<div class="border border-dark border-2 mt-1 mb-2 p-3">
<h3 class="mb-3">Found User {{ data.user.email }}</h3>
{{ show_user(data.user) }}
{{ list_mailboxes("Mailboxes for user", helper.mailbox_count(data.user) , helper.mailbox_list(data.user) ) }}
{{ list_alias(helper.alias_count(data.user) ,helper.alias_list(data.user)) }}
</div>
{% endif %}
{% if data.user_audit_log %}
{% if data.user %}
<div class="border border-dark border-2 mt-1 mb-2 p-3">
<h3 class="mb-3">Found User {{ data.user.email }}</h3>
{{ show_user(data.user) }}
{{ list_mailboxes("Mailboxes for user", helper.mailbox_count(data.user) , helper.mailbox_list(data.user) ) }}
{{ list_alias(helper.alias_count(data.user) ,helper.alias_list(data.user)) }}
</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 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 %}
<div class="border border-dark mt-1 mb-2 p-3">
<h3 class="mb-3">Found Mailbox {{ mailbox.email }}</h3>
{{ list_mailboxes("Mailbox found", 1, [mailbox]) }}
{{ show_user(mailbox.user) }}
</div>
{% endfor %}
{% if data.deleted_alias %}
<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 class="mb-3">Found DeletedAlias {{ data.deleted_alias.email }}</h3>
{{ show_deleted_alias(data.deleted_alias) }}
{{ list_alias_audit_log(data.deleted_alias_audit_log) }}
</div>
{% endif %}
{% if data.domain_deleted_alias %}
<div class="border border-dark mt-1 mb-2 p-3">
<h3 class="mb-3">Found Mailbox {{ mailbox.email }}</h3>
{{ list_mailboxes("Mailbox found", 1, [mailbox]) }}
{{ show_user(mailbox.user) }}
</div>
{% endfor %}
{% if data.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 %}
<div class="border border-dark mt-1 mb-2 p-3">
<h3 class="mb-3">Found DeletedAlias {{ data.deleted_alias.email }}</h3>
{{ show_deleted_alias(data.deleted_alias) }}
{{ list_alias_audit_log(data.deleted_alias_audit_log) }}
</div>
{% 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 %}

View File

@ -43,7 +43,7 @@
You can change the plan at any moment.
<br />
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>.
<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.

View File

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

View File

@ -91,7 +91,6 @@
<br />
Some domain registrars (Namecheap, CloudFlare, etc) might also use <em>@</em> for the root domain.
</div>
{% for record in expected_mx_records %}
<div class="mb-3 p-3 dns-record">
@ -108,7 +107,6 @@
data-clipboard-text="{{ record.domain }}">{{ record.domain }}</em>
</div>
{% endfor %}
<form method="post" action="#mx-form">
{{ csrf_form.csrf_token }}
<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>
</div>
<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" />
Authenticate with Proton
</a>
@ -38,4 +39,4 @@
{% endif %}
</div>
</div>
{% endblock %}
{% endblock %}

View File

@ -11,7 +11,7 @@
<div>
<a class="buy-with-crypto"
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>
</div>
<div class="mt-2">

View File

@ -77,6 +77,11 @@
<div class="text-center mx-md-auto mb-8 mt-6">
<h1>Upgrade to unlock premium features</h1>
</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 %}
<div class="alert alert-info mt-0 mb-6">
@ -306,7 +311,7 @@
<div class="card-body">
<div class="text-center">
<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">
<button class="btn btn-primary btn-lg w-100"
onclick="upgradePaddle({{ PADDLE_YEARLY_PRODUCT_ID }})">Upgrade to Premium</button>
@ -471,7 +476,7 @@
rel="noopener noreferrer">
Upgrade to Premium - cryptocurrency
<br />
$30 / year
$36 / year
<i class="fe fe-external-link"></i>
</a>
</div>

View File

@ -1,5 +1,5 @@
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
@ -29,3 +29,23 @@ def test_alias_create_from_partner_flags_also_the_user():
flush=True,
)
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

@ -144,6 +144,21 @@ def test_login_case_from_web():
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():
email = random_email()
user = User.create(email, commit=True)