Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
01dba12ed0 | |||
c872d43c3d | |||
3e6867bc17 | |||
a829074584 | |||
25834e8f61 | |||
a62b43b7c4 |
@ -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
|
||||||
|
@ -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.
|
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
@ -140,8 +161,9 @@ 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)
|
||||||
|
try:
|
||||||
|
# Will create a new SL User with a random password
|
||||||
new_user = User.create(
|
new_user = User.create(
|
||||||
email=canonical_email,
|
email=canonical_email,
|
||||||
name=self.link_request.name,
|
name=self.link_request.name,
|
||||||
@ -149,6 +171,45 @@ class NewUserStrategy(ClientMergeStrategy):
|
|||||||
activated=True,
|
activated=True,
|
||||||
from_partner=self.link_request.from_partner,
|
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,
|
||||||
@ -162,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):
|
||||||
|
@ -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
|
||||||
|
@ -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")
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
alias_id=alias_id, website_email=email
|
||||||
|
)
|
||||||
|
if contact:
|
||||||
return __update_contact_if_needed(contact, name, mail_from)
|
return __update_contact_if_needed(contact, name, mail_from)
|
||||||
return ContactCreateResult(contact, created=True, error=None)
|
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
|
||||||
|
)
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -227,7 +227,10 @@ def alias_contact_manager(alias_id):
|
|||||||
|
|
||||||
page = 0
|
page = 0
|
||||||
if request.args.get("page"):
|
if request.args.get("page"):
|
||||||
|
try:
|
||||||
page = int(request.args.get("page"))
|
page = int(request.args.get("page"))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
query = request.args.get("query") or ""
|
query = request.args.get("query") or ""
|
||||||
|
|
||||||
|
@ -71,7 +71,10 @@ def index():
|
|||||||
|
|
||||||
page = 0
|
page = 0
|
||||||
if request.args.get("page"):
|
if request.args.get("page"):
|
||||||
|
try:
|
||||||
page = int(request.args.get("page"))
|
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"):
|
||||||
|
@ -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
|
||||||
|
@ -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"):
|
||||||
|
try:
|
||||||
page = int(request.args.get("page"))
|
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)
|
||||||
|
70
app/app/jobs/send_event_job.py
Normal file
70
app/app/jobs/send_event_job.py
Normal 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,
|
||||||
|
)
|
@ -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
|
||||||
@ -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(
|
||||||
|
@ -158,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)
|
||||||
|
|
||||||
@ -319,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"
|
||||||
@ -333,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"
|
||||||
@ -565,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
|
||||||
@ -1221,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()
|
||||||
|
|
||||||
@ -1237,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()
|
||||||
|
|
||||||
@ -1279,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
|
||||||
@ -1337,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()
|
||||||
|
|
||||||
@ -1385,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"
|
||||||
@ -1406,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()
|
||||||
|
|
||||||
@ -1428,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()
|
||||||
|
|
||||||
@ -1581,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])
|
||||||
@ -1666,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
|
||||||
@ -2069,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
|
||||||
@ -2345,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(
|
||||||
@ -2371,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)
|
||||||
@ -2529,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(
|
||||||
@ -2572,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)
|
||||||
@ -2632,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"
|
||||||
@ -2646,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:
|
||||||
@ -2747,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])
|
||||||
|
|
||||||
@ -2884,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)
|
||||||
@ -2906,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()
|
||||||
@ -2945,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"
|
||||||
@ -3190,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()
|
||||||
|
|
||||||
@ -3210,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")
|
||||||
@ -3351,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."""
|
||||||
@ -3452,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"
|
||||||
@ -3626,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"
|
||||||
@ -3749,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"
|
||||||
@ -3759,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)
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
39
app/cron.py
39
app/cron.py
@ -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)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -7000,7 +7000,6 @@ unfunded
|
|||||||
unglazed
|
unglazed
|
||||||
ungloved
|
ungloved
|
||||||
unglue
|
unglue
|
||||||
ungodly
|
|
||||||
ungraded
|
ungraded
|
||||||
ungreased
|
ungreased
|
||||||
unguarded
|
unguarded
|
||||||
|
@ -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')
|
@ -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)
|
@ -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')
|
@ -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')
|
@ -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')
|
62
app/oneshot/send_lifetime_user_events.py
Normal file
62
app/oneshot/send_lifetime_user_events.py
Normal 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}")
|
@ -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}")
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
<th scope="col">Verified</th>
|
<th scope="col">Verified</th>
|
||||||
<th scope="col">Status</th>
|
<th scope="col">Status</th>
|
||||||
<th scope="col">Paid</th>
|
<th scope="col">Paid</th>
|
||||||
|
<th scope="col">Premium</th>
|
||||||
<th>Subscription</th>
|
<th>Subscription</th>
|
||||||
<th>Created At</th>
|
<th>Created At</th>
|
||||||
<th>Updated At</th>
|
<th>Updated At</th>
|
||||||
@ -20,24 +21,31 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ user.id }}</td>
|
<td>{{ user.id }}</td>
|
||||||
<td><a href="?email={{ user.email }}">{{ user.email }}</a></td>
|
<td>
|
||||||
|
<a href="?email={{ user.email }}">{{ user.email }}</a>
|
||||||
|
</td>
|
||||||
{% if user.activated %}
|
{% if user.activated %}
|
||||||
|
|
||||||
<td class="text-success">Activated</td>
|
<td class="text-success">Activated</td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td class="text-warning">Pending</td>
|
<td class="text-warning">Pending</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.disabled %}
|
{% if user.disabled %}
|
||||||
|
|
||||||
<td class="text-danger">Disabled</td>
|
<td class="text-danger">Disabled</td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td class="text-success">Enabled</td>
|
<td class="text-success">Enabled</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td>{{ "yes" if user.is_paid() else "No" }}</td>
|
<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.get_active_subscription() }}</td>
|
||||||
<td>{{ user.created_at }}</td>
|
<td>{{ user.created_at }}</td>
|
||||||
<td>{{ user.updated_at }}</td>
|
<td>{{ user.updated_at }}</td>
|
||||||
{% if pu %}
|
{% if pu %}
|
||||||
|
|
||||||
<td><a href="?email={{ pu.partner_email }}">{{ pu.partner_email }}</a></td>
|
<td>
|
||||||
|
<a href="?email={{ pu.partner_email }}">{{ pu.partner_email }}</a>
|
||||||
|
</td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td>No</td>
|
<td>No</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -61,14 +69,13 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for mailbox in mboxes %}
|
{% for mailbox in mboxes %}
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ mailbox.id }}</td>
|
<td>{{ mailbox.id }}</td>
|
||||||
<td><a href="?email={{ mailbox.email }}">{{ mailbox.email }}</a></td>
|
|
||||||
<td>{{ "Yes" if mailbox.verified else "No" }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
{{ mailbox.created_at }}
|
<a href="?email={{ mailbox.email }}">{{ mailbox.email }}</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ "Yes" if mailbox.verified else "No" }}</td>
|
||||||
|
<td>{{ mailbox.created_at }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -82,25 +89,20 @@
|
|||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>Alias ID</th>
|
||||||
Alias ID
|
<th>Email</th>
|
||||||
</th>
|
<th>Enabled</th>
|
||||||
<th>
|
<th>Created At</th>
|
||||||
Email
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Enabled
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Created At
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for alias in aliases %}
|
{% for alias in aliases %}
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ alias.id }}</td>
|
<td>{{ alias.id }}</td>
|
||||||
<td><a href="?email={{ alias.email }}">{{ alias.email }}</a></td>
|
<td>
|
||||||
|
<a href="?email={{ alias.email }}">{{ alias.email }}</a>
|
||||||
|
</td>
|
||||||
<td>{{ "Yes" if alias.enabled else "No" }}</td>
|
<td>{{ "Yes" if alias.enabled else "No" }}</td>
|
||||||
<td>{{ alias.created_at }}</td>
|
<td>{{ alias.created_at }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -174,10 +176,13 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in alias_audit_log %}
|
{% for entry in alias_audit_log %}
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ entry.user_id }}</td>
|
<td>{{ entry.user_id }}</td>
|
||||||
<td>{{ entry.alias_id }}</td>
|
<td>{{ entry.alias_id }}</td>
|
||||||
<td><a href="?email={{ entry.alias_email }}">{{ entry.alias_email }}</a></td>
|
<td>
|
||||||
|
<a href="?email={{ entry.alias_email }}">{{ entry.alias_email }}</a>
|
||||||
|
</td>
|
||||||
<td>{{ entry.action }}</td>
|
<td>{{ entry.action }}</td>
|
||||||
<td>{{ entry.message }}</td>
|
<td>{{ entry.message }}</td>
|
||||||
<td>{{ entry.created_at }}</td>
|
<td>{{ entry.created_at }}</td>
|
||||||
@ -199,8 +204,11 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in user_audit_log %}
|
{% for entry in user_audit_log %}
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="?email={{ entry.user_email }}">{{ entry.user_email }}</a></td>
|
<td>
|
||||||
|
<a href="?email={{ entry.user_email }}">{{ entry.user_email }}</a>
|
||||||
|
</td>
|
||||||
<td>{{ entry.action }}</td>
|
<td>{{ entry.action }}</td>
|
||||||
<td>{{ entry.message }}</td>
|
<td>{{ entry.message }}</td>
|
||||||
<td>{{ entry.created_at }}</td>
|
<td>{{ entry.created_at }}</td>
|
||||||
@ -218,27 +226,28 @@
|
|||||||
<input type="text"
|
<input type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
name="email"
|
name="email"
|
||||||
value="{{ email or '' }}"/>
|
value="{{ email or '' }}" />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Submit</button>
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% if data.no_match and email %}
|
{% if data.no_match and email %}
|
||||||
|
|
||||||
<div class="border border-dark border-2 mt-1 mb-2 p-3 alert alert-warning"
|
<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>
|
role="alert">No user, alias or mailbox found for {{ email }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if data.alias %}
|
{% 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 Alias {{ data.alias.email }}</h3>
|
<h3 class="mb-3">Found Alias {{ data.alias.email }}</h3>
|
||||||
{{ list_alias(1,[data.alias]) }}
|
{{ list_alias(1,[data.alias]) }}
|
||||||
{{ list_alias_audit_log(data.alias_audit_log) }}
|
{{ list_alias_audit_log(data.alias_audit_log) }}
|
||||||
{{ list_mailboxes("Mailboxes for alias", helper.alias_mailbox_count(data.alias), helper.alias_mailboxes(data.alias)) }}
|
{{ list_mailboxes("Mailboxes for alias", helper.alias_mailbox_count(data.alias) , helper.alias_mailboxes(data.alias)) }}
|
||||||
{{ show_user(data.alias.user) }}
|
{{ show_user(data.alias.user) }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if data.user %}
|
{% 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">Found User {{ data.user.email }}</h3>
|
<h3 class="mb-3">Found User {{ data.user.email }}</h3>
|
||||||
{{ show_user(data.user) }}
|
{{ show_user(data.user) }}
|
||||||
@ -247,12 +256,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if data.user_audit_log %}
|
{% if data.user_audit_log %}
|
||||||
|
|
||||||
<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">Audit log entries for user {{ data.query }}</h3>
|
||||||
{{ list_user_audit_log(data.user_audit_log) }}
|
{{ list_user_audit_log(data.user_audit_log) }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if data.mailbox_count > 10 %}
|
{% if data.mailbox_count > 10 %}
|
||||||
|
|
||||||
<h3>Found more than 10 mailboxes for {{ email }}. Showing the last 10</h3>
|
<h3>Found more than 10 mailboxes for {{ email }}. Showing the last 10</h3>
|
||||||
{% elif data.mailbox_count > 0 %}
|
{% elif data.mailbox_count > 0 %}
|
||||||
<h3>Found {{ data.mailbox_count }} mailbox(es) for {{ email }}</h3>
|
<h3>Found {{ data.mailbox_count }} mailbox(es) for {{ email }}</h3>
|
||||||
|
@ -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.
|
||||||
|
@ -94,4 +94,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
40
app/tests/jobs/test_send_event_to_webhook.py
Normal file
40
app/tests/jobs/test_send_event_to_webhook.py
Normal 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)
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -314,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
|
||||||
|
Reference in New Issue
Block a user