Compare commits

...

7 Commits

Author SHA1 Message Date
3c77f8af4b 4.61.0
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m9s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 4m14s
Build-Release-Image / Merge-Images (push) Successful in 47s
Build-Release-Image / Create-Release (push) Successful in 16s
Build-Release-Image / Notify (push) Successful in 3s
2024-11-29 12:00:12 +00:00
545eeda79b 4.59.5
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m2s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m43s
Build-Release-Image / Merge-Images (push) Successful in 49s
Build-Release-Image / Create-Release (push) Successful in 21s
Build-Release-Image / Notify (push) Successful in 8s
2024-11-18 12:00:06 +00:00
01dba12ed0 4.59.3
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m43s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m57s
Build-Release-Image / Merge-Images (push) Successful in 53s
Build-Release-Image / Create-Release (push) Successful in 8s
Build-Release-Image / Notify (push) Successful in 3s
2024-11-16 12:00:07 +00:00
c872d43c3d 4.59.2
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m7s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 4m46s
Build-Release-Image / Merge-Images (push) Successful in 14s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 5s
2024-11-14 12:00:07 +00:00
3e6867bc17 4.58
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m7s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m49s
Build-Release-Image / Merge-Images (push) Successful in 15s
Build-Release-Image / Create-Release (push) Successful in 8s
Build-Release-Image / Notify (push) Successful in 3s
2024-11-07 12:00:06 +00:00
a829074584 4.57.2
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m6s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m48s
Build-Release-Image / Merge-Images (push) Successful in 20s
Build-Release-Image / Create-Release (push) Successful in 11s
Build-Release-Image / Notify (push) Successful in 2s
2024-11-06 12:00:08 +00:00
25834e8f61 4.56.3
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m15s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m45s
Build-Release-Image / Merge-Images (push) Successful in 15s
Build-Release-Image / Create-Release (push) Successful in 10s
Build-Release-Image / Notify (push) Successful in 21s
2024-11-05 12:00:07 +00:00
47 changed files with 1252 additions and 475 deletions

View File

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

View File

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

View File

@ -3,12 +3,17 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
import arrow
import sqlalchemy.exc
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 (
@ -31,6 +36,7 @@ from app.utils import random_string
class SLPlanType(Enum): class SLPlanType(Enum):
Free = 1 Free = 1
Premium = 2 Premium = 2
PremiumLifetime = 3
@dataclass @dataclass
@ -54,8 +60,24 @@ 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)
is_lifetime = plan.type == SLPlanType.PremiumLifetime
if plan.type == SLPlanType.Free: if plan.type == SLPlanType.Free:
if sub is not None: if sub is not None:
LOG.i( LOG.i(
@ -64,30 +86,37 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
PartnerSubscription.delete(sub.id) PartnerSubscription.delete(sub.id)
agent.record_custom_event("PlanChange", {"plan": "free"}) agent.record_custom_event("PlanChange", {"plan": "free"})
else: else:
end_time = plan.expiration
if plan.type == SLPlanType.PremiumLifetime:
end_time = None
if sub is None: if sub is None:
LOG.i( LOG.i(
f"Creating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]" f"Creating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
) )
create_partner_subscription( create_partner_subscription(
partner_user=partner_user, partner_user=partner_user,
expiration=plan.expiration, expiration=end_time,
lifetime=is_lifetime,
msg="Upgraded via partner. User did not have a previous partner subscription", msg="Upgraded via partner. User did not have a previous partner subscription",
) )
agent.record_custom_event("PlanChange", {"plan": "premium", "type": "new"}) agent.record_custom_event("PlanChange", {"plan": "premium", "type": "new"})
else: else:
if sub.end_at != plan.expiration: if sub.end_at != plan.expiration or sub.lifetime != is_lifetime:
LOG.i( LOG.i(
f"Updating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]" f"Updating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
) )
agent.record_custom_event( agent.record_custom_event(
"PlanChange", {"plan": "premium", "type": "extension"} "PlanChange", {"plan": "premium", "type": "extension"}
) )
sub.end_at = plan.expiration sub.end_at = plan.expiration if not is_lifetime else None
sub.lifetime = is_lifetime
emit_user_audit_log( emit_user_audit_log(
user=partner_user.user, user=partner_user.user,
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,15 +169,56 @@ class ClientMergeStrategy(ABC):
class NewUserStrategy(ClientMergeStrategy): class NewUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult: def process(self) -> LinkResult:
# Will create a new SL User with a random password
canonical_email = canonicalize_email(self.link_request.email) canonical_email = canonicalize_email(self.link_request.email)
new_user = User.create( try:
email=canonical_email, # Will create a new SL User with a random password
name=self.link_request.name, new_user = User.create(
password=random_string(20), email=canonical_email,
activated=True, name=self.link_request.name,
from_partner=self.link_request.from_partner, password=random_string(20),
activated=True,
from_partner=self.link_request.from_partner,
)
self.create_partner_user(new_user)
Session.commit()
if not new_user.created_by_partner:
send_welcome_email(new_user)
agent.record_custom_event(
"PartnerUserCreation", {"partner": self.partner.name}
)
return LinkResult(
user=new_user,
strategy=self.__class__.__name__,
)
except (UniqueViolation, sqlalchemy.exc.IntegrityError) as e:
LOG.debug(f"Got the duplicate user error: {e}")
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 +232,7 @@ class NewUserStrategy(ClientMergeStrategy):
partner_user, partner_user,
self.link_request.plan, self.link_request.plan,
) )
Session.commit() return partner_user
if not new_user.created_by_partner:
send_welcome_email(new_user)
agent.record_custom_event("PartnerUserCreation", {"partner": self.partner.name})
return LinkResult(
user=new_user,
strategy=self.__class__.__name__,
)
class ExistingUnlinkedUserStrategy(ClientMergeStrategy): class ExistingUnlinkedUserStrategy(ClientMergeStrategy):

View File

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

View File

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

View File

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

View File

@ -91,6 +91,7 @@ def create_contact(
alias_id = alias.id 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,
@ -100,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:
@ -110,11 +112,11 @@ 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) return ContactCreateResult(contact, created=True, error=None)
except IntegrityError: except IntegrityError:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -174,7 +174,12 @@ def setting():
flash("Your preference has been updated", "success") flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting")) return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "random-alias-suffix": elif request.form.get("form-name") == "random-alias-suffix":
scheme = int(request.form.get("random-alias-suffix-generator")) try:
scheme = int(request.form.get("random-alias-suffix-generator"))
except ValueError:
flash("Invalid value", "error")
return redirect(url_for("dashboard.setting"))
if AliasSuffixEnum.has_value(scheme): if AliasSuffixEnum.has_value(scheme):
current_user.random_alias_suffix = scheme current_user.random_alias_suffix = scheme
Session.commit() Session.commit()

View File

@ -1345,17 +1345,16 @@ def get_queue_id(msg: Message) -> Optional[str]:
received_header = str(msg[headers.RECEIVED]) received_header = str(msg[headers.RECEIVED])
if not received_header: if not received_header:
return return None
# received_header looks like 'from mail-wr1-x434.google.com (mail-wr1-x434.google.com [IPv6:2a00:1450:4864:20::434])\r\n\t(using TLSv1.3 with cipher TLS_AES_128_GCM_SHA256 (128/128 bits))\r\n\t(No client certificate requested)\r\n\tby mx1.simplelogin.co (Postfix) with ESMTPS id 4FxQmw1DXdz2vK2\r\n\tfor <jglfdjgld@alias.com>; Fri, 4 Jun 2021 14:55:43 +0000 (UTC)' # received_header looks like 'from mail-wr1-x434.google.com (mail-wr1-x434.google.com [IPv6:2a00:1450:4864:20::434])\r\n\t(using TLSv1.3 with cipher TLS_AES_128_GCM_SHA256 (128/128 bits))\r\n\t(No client certificate requested)\r\n\tby mx1.simplelogin.co (Postfix) with ESMTPS id 4FxQmw1DXdz2vK2\r\n\tfor <jglfdjgld@alias.com>; Fri, 4 Jun 2021 14:55:43 +0000 (UTC)'
search_result = re.search("with ESMTPS id [0-9a-zA-Z]{1,}", received_header) search_result = re.search(r"with E?SMTP[AS]? id ([0-9a-zA-Z]{1,})", received_header)
if not search_result: if search_result:
return return search_result.group(1)
search_result = re.search("\(Postfix\)\r\n\tid ([a-zA-Z0-9]{1,});", received_header)
# the "with ESMTPS id 4FxQmw1DXdz2vK2" part if search_result:
with_esmtps = received_header[search_result.start() : search_result.end()] return search_result.group(1)
return None
return with_esmtps[len("with ESMTPS id ") :]
def should_ignore_bounce(mail_from: str) -> bool: def should_ignore_bounce(mail_from: str) -> bool:

View File

@ -1,6 +1,5 @@
import dataclasses import dataclasses
import secrets import secrets
import random
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
import arrow import arrow
@ -13,11 +12,13 @@ from app.email_utils import (
email_can_be_used_as_mailbox, email_can_be_used_as_mailbox,
send_email, send_email,
render, render,
get_email_domain_part,
) )
from app.email_validation import is_valid_email from app.email_validation import is_valid_email
from app.log import LOG from app.log import LOG
from app.models import User, Mailbox, Job, MailboxActivation from app.models import User, Mailbox, Job, MailboxActivation, Alias
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import canonicalize_email, sanitize_email
@dataclasses.dataclass @dataclasses.dataclass
@ -53,6 +54,7 @@ def create_mailbox(
use_digit_codes: bool = False, use_digit_codes: bool = False,
send_link: bool = True, send_link: bool = True,
) -> CreateMailboxOutput: ) -> CreateMailboxOutput:
email = sanitize_email(email)
if not user.is_premium(): if not user.is_premium():
LOG.i( LOG.i(
f"User {user} has tried to create mailbox with {email} but is not premium" f"User {user} has tried to create mailbox with {email} but is not premium"
@ -105,7 +107,10 @@ def create_mailbox(
def delete_mailbox( def delete_mailbox(
user: User, mailbox_id: int, transfer_mailbox_id: Optional[int] user: User,
mailbox_id: int,
transfer_mailbox_id: Optional[int],
send_mail: bool = True,
) -> Mailbox: ) -> Mailbox:
mailbox = Mailbox.get(mailbox_id) mailbox = Mailbox.get(mailbox_id)
@ -151,6 +156,7 @@ def delete_mailbox(
"transfer_mailbox_id": transfer_mailbox_id "transfer_mailbox_id": transfer_mailbox_id
if transfer_mailbox_id and transfer_mailbox_id > 0 if transfer_mailbox_id and transfer_mailbox_id > 0
else None, else None,
"send_mail": send_mail,
}, },
run_at=arrow.now(), run_at=arrow.now(),
commit=True, commit=True,
@ -233,7 +239,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(
@ -329,3 +335,56 @@ def perform_mailbox_email_change(mailbox_id: int) -> MailboxEmailChangeResult:
message="Invalid link", message="Invalid link",
message_category="error", message_category="error",
) )
def __get_alias_mailbox_from_email(
email_address: str, alias: Alias
) -> Optional[Mailbox]:
for mailbox in alias.mailboxes:
if mailbox.email == email_address:
return mailbox
for authorized_address in mailbox.authorized_addresses:
if authorized_address.email == email_address:
LOG.d(
"Found an authorized address for %s %s %s",
alias,
mailbox,
authorized_address,
)
return mailbox
return None
def __get_alias_mailbox_from_email_or_canonical_email(
email_address: str, alias: Alias
) -> Optional[Mailbox]:
# We need to first check for the uncanonicalized version because we still have users in the db with the
# email non canonicalized. So if it matches the already existing one use that, otherwise check the canonical one
mbox = __get_alias_mailbox_from_email(email_address, alias)
if mbox is not None:
return mbox
canonical_email = canonicalize_email(email_address)
if canonical_email != email_address:
return __get_alias_mailbox_from_email(canonical_email, alias)
return None
def get_mailbox_for_reply_phase(
envelope_mail_from: str, header_mail_from: str, alias
) -> Optional[Mailbox]:
"""return the corresponding mailbox given the mail_from and alias
Usually the mail_from=mailbox.email but it can also be one of the authorized address
"""
mbox = __get_alias_mailbox_from_email_or_canonical_email(envelope_mail_from, alias)
if mbox is not None:
return mbox
if not header_mail_from:
return None
envelope_from_domain = get_email_domain_part(envelope_mail_from)
header_from_domain = get_email_domain_part(header_mail_from)
if envelope_from_domain != header_from_domain:
return None
# For services that use VERP sending (envelope from has encoded data to account for bounces)
# if the domain is the same in the header from as the envelope from we can use the header from
return __get_alias_mailbox_from_email_or_canonical_email(header_mail_from, alias)

View File

@ -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"
@ -3696,7 +3778,8 @@ class PartnerSubscription(Base, ModelMixin):
) )
# when the partner subscription ends # when the partner subscription ends
end_at = sa.Column(ArrowType, nullable=False, index=True) end_at = sa.Column(ArrowType, nullable=True, index=True)
lifetime = sa.Column(sa.Boolean, default=False, nullable=False, server_default="0")
partner_user = orm.relationship(PartnerUser) partner_user = orm.relationship(PartnerUser)
@ -3718,7 +3801,9 @@ class PartnerSubscription(Base, ModelMixin):
return None return None
def is_active(self): def is_active(self):
return self.end_at > arrow.now().shift(days=-_PARTNER_SUBSCRIPTION_GRACE_DAYS) return self.lifetime or self.end_at > arrow.now().shift(
days=-_PARTNER_SUBSCRIPTION_GRACE_DAYS
)
# endregion # endregion
@ -3749,6 +3834,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 +3846,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)

View File

@ -33,12 +33,14 @@ def create_partner_user(
def create_partner_subscription( def create_partner_subscription(
partner_user: PartnerUser, partner_user: PartnerUser,
expiration: Optional[Arrow], expiration: Optional[Arrow] = None,
lifetime: bool = False,
msg: Optional[str] = None, msg: Optional[str] = None,
) -> PartnerSubscription: ) -> PartnerSubscription:
instance = PartnerSubscription.create( instance = PartnerSubscription.create(
partner_user_id=partner_user.id, partner_user_id=partner_user.id,
end_at=expiration, end_at=expiration,
lifetime=lifetime,
) )
message = "User upgraded through partner subscription" message = "User upgraded through partner subscription"

View File

@ -16,6 +16,7 @@ PROTON_ERROR_CODE_HV_NEEDED = 9001
PLAN_FREE = 1 PLAN_FREE = 1
PLAN_PREMIUM = 2 PLAN_PREMIUM = 2
PLAN_PREMIUM_LIFETIME = 3
@dataclass @dataclass
@ -112,10 +113,13 @@ class HttpProtonClient(ProtonClient):
if plan_value == PLAN_FREE: if plan_value == PLAN_FREE:
plan = SLPlan(type=SLPlanType.Free, expiration=None) plan = SLPlan(type=SLPlanType.Free, expiration=None)
elif plan_value == PLAN_PREMIUM: elif plan_value == PLAN_PREMIUM:
expiration = info.get("Expiration", "1")
plan = SLPlan( plan = SLPlan(
type=SLPlanType.Premium, type=SLPlanType.Premium,
expiration=Arrow.fromtimestamp(info["PlanExpiration"], tzinfo="utc"), expiration=Arrow.fromtimestamp(expiration, tzinfo="utc"),
) )
elif plan_value == PLAN_PREMIUM_LIFETIME:
plan = SLPlan(SLPlanType.PremiumLifetime, expiration=None)
else: else:
raise Exception(f"Invalid value for plan: {plan_value}") raise Exception(f"Invalid value for plan: {plan_value}")

View File

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

View File

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

View File

@ -14,15 +14,28 @@ jobs:
- name: SimpleLogin Custom Domain check - name: SimpleLogin Custom Domain check
command: python /code/cron.py -j check_custom_domain command: python /code/cron.py -j check_custom_domain
shell: /bin/bash shell: /bin/bash
schedule: "15 2 * * *" schedule: "15 */4 * * *"
captureStderr: true captureStderr: true
concurrencyPolicy: Forbid
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: "*/5 * * * *"
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 +44,7 @@ jobs:
captureStderr: true captureStderr: true
concurrencyPolicy: Forbid concurrencyPolicy: Forbid
- name: SimpleLogin Delete Logs - name: SimpleLogin Delete Logs
command: python /code/cron.py -j delete_logs command: python /code/cron.py -j delete_logs
shell: /bin/bash shell: /bin/bash

View File

@ -149,6 +149,7 @@ from app.handler.unsubscribe_generator import UnsubscribeGenerator
from app.handler.unsubscribe_handler import UnsubscribeHandler from app.handler.unsubscribe_handler import UnsubscribeHandler
from app.log import LOG, set_message_id from app.log import LOG, set_message_id
from app.mail_sender import sl_sendmail from app.mail_sender import sl_sendmail
from app.mailbox_utils import get_mailbox_for_reply_phase
from app.message_utils import message_to_bytes from app.message_utils import message_to_bytes
from app.models import ( from app.models import (
Alias, Alias,
@ -172,12 +173,14 @@ from app.pgp_utils import (
sign_data, sign_data,
load_public_key_and_check, load_public_key_and_check,
) )
from app.utils import sanitize_email, canonicalize_email from app.utils import sanitize_email
from init_app import load_pgp_public_keys 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 +211,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 +563,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 +584,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
@ -1002,7 +1009,6 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
return False, status.E503 return False, status.E503
user = alias.user user = alias.user
mail_from = envelope.mail_from
if not user.can_send_or_receive(): if not user.can_send_or_receive():
LOG.i(f"User {user} cannot send emails") LOG.i(f"User {user} cannot send emails")
@ -1016,13 +1022,15 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
return False, dmarc_delivery_status return False, dmarc_delivery_status
# Anti-spoofing # Anti-spoofing
mailbox = get_mailbox_from_mail_from(mail_from, alias) mailbox = get_mailbox_for_reply_phase(
envelope.mail_from, get_header_unicode(msg[headers.FROM]), alias
)
if not mailbox: if not mailbox:
if alias.disable_email_spoofing_check: if alias.disable_email_spoofing_check:
# ignore this error, use default alias mailbox # ignore this error, use default alias mailbox
LOG.w( LOG.w(
"ignore unknown sender to reverse-alias %s: %s -> %s", "ignore unknown sender to reverse-alias %s: %s -> %s",
mail_from, envelope.mail_from,
alias, alias,
contact, contact,
) )
@ -1361,32 +1369,6 @@ def replace_original_message_id(alias: Alias, email_log: EmailLog, msg: Message)
msg[headers.REFERENCES] = " ".join(new_message_ids) msg[headers.REFERENCES] = " ".join(new_message_ids)
def get_mailbox_from_mail_from(mail_from: str, alias) -> Optional[Mailbox]:
"""return the corresponding mailbox given the mail_from and alias
Usually the mail_from=mailbox.email but it can also be one of the authorized address
"""
def __check(email_address: str, alias: Alias) -> Optional[Mailbox]:
for mailbox in alias.mailboxes:
if mailbox.email == email_address:
return mailbox
for authorized_address in mailbox.authorized_addresses:
if authorized_address.email == email_address:
LOG.d(
"Found an authorized address for %s %s %s",
alias,
mailbox,
authorized_address,
)
return mailbox
return None
# We need to first check for the uncanonicalized version because we still have users in the db with the
# email non canonicalized. So if it matches the already existing one use that, otherwise check the canonical one
return __check(mail_from, alias) or __check(canonicalize_email(mail_from), alias)
def handle_unknown_mailbox( def handle_unknown_mailbox(
envelope, msg, reply_email: str, user: User, alias: Alias, contact: Contact envelope, msg, reply_email: str, user: User, alias: Alias, contact: Contact
): ):

View File

@ -164,6 +164,8 @@ def delete_mailbox_job(job: Job):
Session.commit() Session.commit()
LOG.d("Mailbox %s %s deleted", mailbox_id, mailbox_email) LOG.d("Mailbox %s %s deleted", mailbox_id, mailbox_email)
if not job.payload.get("send_mail", True):
return
if alias_transferred_to: if alias_transferred_to:
send_email( send_email(
user.email, user.email,

View File

@ -1,6 +1,4 @@
abacus abacus
abdomen
abdominal
abide abide
abiding abiding
ability ability
@ -1031,7 +1029,6 @@ chosen
chowder chowder
chowtime chowtime
chrome chrome
chubby
chuck chuck
chug chug
chummy chummy
@ -2041,8 +2038,6 @@ dwindling
dynamic dynamic
dynamite dynamite
dynasty dynasty
dyslexia
dyslexic
each each
eagle eagle
earache earache
@ -2081,7 +2076,6 @@ eatery
eating eating
eats eats
ebay ebay
ebony
ebook ebook
ecard ecard
eccentric eccentric
@ -2375,8 +2369,6 @@ exclude
excluding excluding
exclusion exclusion
exclusive exclusive
excretion
excretory
excursion excursion
excusable excusable
excusably excusably
@ -2396,8 +2388,6 @@ existing
exit exit
exodus exodus
exonerate exonerate
exorcism
exorcist
expand expand
expanse expanse
expansion expansion
@ -2483,7 +2473,6 @@ fanning
fantasize fantasize
fantastic fantastic
fantasy fantasy
fascism
fastball fastball
faster faster
fasting fasting
@ -3028,7 +3017,6 @@ guiding
guileless guileless
guise guise
gulf gulf
gullible
gully gully
gulp gulp
gumball gumball
@ -3040,10 +3028,6 @@ gurgle
gurgling gurgling
guru guru
gush gush
gusto
gusty
gutless
guts
gutter gutter
guy guy
guzzler guzzler
@ -3242,8 +3226,6 @@ humble
humbling humbling
humbly humbly
humid humid
humiliate
humility
humming humming
hummus hummus
humongous humongous
@ -3271,7 +3253,6 @@ hurray
hurricane hurricane
hurried hurried
hurry hurry
hurt
husband husband
hush hush
husked husked
@ -3292,8 +3273,6 @@ hypnotic
hypnotism hypnotism
hypnotist hypnotist
hypnotize hypnotize
hypocrisy
hypocrite
ibuprofen ibuprofen
ice ice
iciness iciness
@ -3323,7 +3302,6 @@ image
imaginary imaginary
imagines imagines
imaging imaging
imbecile
imitate imitate
imitation imitation
immerse immerse
@ -3746,7 +3724,6 @@ machine
machinist machinist
magazine magazine
magenta magenta
maggot
magical magical
magician magician
magma magma
@ -3968,8 +3945,6 @@ multitude
mumble mumble
mumbling mumbling
mumbo mumbo
mummified
mummify
mumps mumps
munchkin munchkin
mundane mundane
@ -4022,8 +3997,6 @@ napped
napping napping
nappy nappy
narrow narrow
nastily
nastiness
national national
native native
nativity nativity
@ -4446,7 +4419,6 @@ pasta
pasted pasted
pastel pastel
pastime pastime
pastor
pastrami pastrami
pasture pasture
pasty pasty
@ -4458,7 +4430,6 @@ path
patience patience
patient patient
patio patio
patriarch
patriot patriot
patrol patrol
patronage patronage
@ -4549,7 +4520,6 @@ pettiness
petty petty
petunia petunia
phantom phantom
phobia
phoenix phoenix
phonebook phonebook
phoney phoney
@ -4608,7 +4578,6 @@ plot
plow plow
ploy ploy
pluck pluck
plug
plunder plunder
plunging plunging
plural plural
@ -4875,7 +4844,6 @@ pupil
puppet puppet
puppy puppy
purchase purchase
pureblood
purebred purebred
purely purely
pureness pureness
@ -5047,7 +5015,6 @@ recharger
recipient recipient
recital recital
recite recite
reckless
reclaim reclaim
recliner recliner
reclining reclining
@ -5440,7 +5407,6 @@ rubdown
ruby ruby
ruckus ruckus
rudder rudder
rug
ruined ruined
rule rule
rumble rumble
@ -5448,7 +5414,6 @@ rumbling
rummage rummage
rumor rumor
runaround runaround
rundown
runner runner
running running
runny runny
@ -5518,7 +5483,6 @@ sandpaper
sandpit sandpit
sandstone sandstone
sandstorm sandstorm
sandworm
sandy sandy
sanitary sanitary
sanitizer sanitizer
@ -5541,7 +5505,6 @@ satisfy
saturate saturate
saturday saturday
sauciness sauciness
saucy
sauna sauna
savage savage
savanna savanna
@ -5552,7 +5515,6 @@ savor
saxophone saxophone
say say
scabbed scabbed
scabby
scalded scalded
scalding scalding
scale scale
@ -5587,7 +5549,6 @@ science
scientist scientist
scion scion
scoff scoff
scolding
scone scone
scoop scoop
scooter scooter
@ -5651,8 +5612,6 @@ sedate
sedation sedation
sedative sedative
sediment sediment
seduce
seducing
segment segment
seismic seismic
seizing seizing
@ -5899,7 +5858,6 @@ skimpily
skincare skincare
skinless skinless
skinning skinning
skinny
skintight skintight
skipper skipper
skipping skipping
@ -6248,17 +6206,12 @@ stifle
stifling stifling
stillness stillness
stilt stilt
stimulant
stimulate
stimuli
stimulus stimulus
stinger stinger
stingily stingily
stinging stinging
stingray stingray
stingy stingy
stinking
stinky
stipend stipend
stipulate stipulate
stir stir
@ -6866,7 +6819,6 @@ unbent
unbiased unbiased
unbitten unbitten
unblended unblended
unblessed
unblock unblock
unbolted unbolted
unbounded unbounded
@ -6947,7 +6899,6 @@ undertone
undertook undertook
undertow undertow
underuse underuse
underwear
underwent underwent
underwire underwire
undesired undesired
@ -7000,7 +6951,6 @@ unfunded
unglazed unglazed
ungloved ungloved
unglue unglue
ungodly
ungraded ungraded
ungreased ungreased
unguarded unguarded
@ -7032,7 +6982,6 @@ uninsured
uninvited uninvited
union union
uniquely uniquely
unisexual
unison unison
unissued unissued
unit unit
@ -7493,8 +7442,6 @@ wheat
whenever whenever
whiff whiff
whimsical whimsical
whinny
whiny
whisking whisking
whoever whoever
whole whole
@ -7600,7 +7547,6 @@ wrongness
wrought wrought
xbox xbox
xerox xerox
yahoo
yam yam
yanking yanking
yapping yapping

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,35 @@
"""empty message
Revision ID: 085f77996ce3
Revises: 0f3ee15b0014
Create Date: 2024-11-26 19:20:32.227899
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '085f77996ce3'
down_revision = '0f3ee15b0014'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('partner_subscription', sa.Column('lifetime', sa.Boolean(), server_default='0', nullable=False))
op.alter_column('partner_subscription', 'end_at',
existing_type=postgresql.TIMESTAMP(),
nullable=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('partner_subscription', 'end_at',
existing_type=postgresql.TIMESTAMP(),
nullable=False)
op.drop_column('partner_subscription', 'lifetime')
# ### end Alembic commands ###

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -57,24 +57,19 @@
{% endblock %} {% endblock %}
{% block default_content %} {% block default_content %}
{% if NOW.timestamp < 1701475201 %} {% if NOW.timestamp < 1733184000 %}
<div class="alert alert-info"> <div class="alert alert-primary">
Black Friday Deal: 33% off on the yearly plan for the <b>first</b> year ($20 instead of $30). Lifetime deal for SimpleLogin Premium and Proton Pass Plus for $199
<a class="btn btn-primary"
href="https://proton.me/pass/black-friday"
target="_blank">Buy now</a>
<br> <br>
Please use this coupon code Available until December 3, 2024.
<em data-toggle="tooltip"
title="Click to copy"
class="clipboard"
data-clipboard-text="BF2023">BF2023</em> during the checkout.
<br>
<img src="/static/images/coupon.png" class="m-2" style="max-width: 300px">
<br>
Available until December 1, 2023.
</div> </div>
{% endif %} {% endif %}
<div class="pb-8"> <div class="pb-8">
<div class="text-center mx-md-auto mb-8 mt-6"> <div class="text-center mx-md-auto mb-4 mt-4">
<h1>Upgrade to unlock premium features</h1> <h1>Upgrade to unlock premium features</h1>
</div> </div>
{% if manual_sub %} {% if manual_sub %}
@ -126,6 +121,11 @@
aria-selected="true">Yearly<span class="badge badge-success position-absolute tab-yearly__badge" aria-selected="true">Yearly<span class="badge badge-success position-absolute tab-yearly__badge"
style="font-size: 12px">Save $18</span></a> style="font-size: 12px">Save $18</span></a>
</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>
<div class="tab-content mb-8"> <div class="tab-content mb-8">
<!-- monthly tab content --> <!-- monthly tab content -->
<div class="tab-pane" <div class="tab-pane"
@ -218,12 +218,12 @@
<div class="card card-md flex-grow-1"> <div class="card card-md flex-grow-1">
<div class="card-body"> <div class="card-body">
<div class="text-center"> <div class="text-center">
<div class="h3">Proton plan</div> <div class="h3">Proton Unlimited</div>
<div class="h3 my-3">Starts at $12.99 / month</div> <div class="h3 my-3">Starts at $12.99 / month</div>
<div class="text-center mt-4 mb-6"> <div class="text-center mt-4 mb-6">
<a class="btn btn-lg btn-outline-primary w-100" <a class="btn btn-lg btn-outline-primary w-100"
role="button" role="button"
href="https://account.proton.me/u/0/mail/upgrade" href="https://account.proton.me/u/0/pass/upgrade"
target="_blank">Upgrade your Proton account</a> target="_blank">Upgrade your Proton account</a>
</div> </div>
</div> </div>
@ -306,7 +306,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>
@ -357,12 +357,12 @@
<div class="card card-md flex-grow-1"> <div class="card card-md flex-grow-1">
<div class="card-body"> <div class="card-body">
<div class="text-center"> <div class="text-center">
<div class="h3">Proton plan</div> <div class="h3">Proton Unlimited</div>
<div class="h3 my-3">Starts at $119.88 / year</div> <div class="h3 my-3">Starts at $119.88 / year</div>
<div class="text-center mt-4 mb-6"> <div class="text-center mt-4 mb-6">
<a class="btn btn-lg btn-outline-primary w-100" <a class="btn btn-lg btn-outline-primary w-100"
role="button" role="button"
href="https://account.proton.me/u/0/mail/upgrade" href="https://account.proton.me/u/0/pass/upgrade"
target="_blank">Upgrade your Proton account</a> target="_blank">Upgrade your Proton account</a>
</div> </div>
</div> </div>
@ -471,7 +471,7 @@
rel="noopener noreferrer"> rel="noopener noreferrer">
Upgrade to Premium - cryptocurrency Upgrade to Premium - cryptocurrency
<br /> <br />
$30 / year $36 / year
<i class="fe fe-external-link"></i> <i class="fe fe-external-link"></i>
</a> </a>
</div> </div>

View File

@ -79,7 +79,14 @@
</a> </a>
</div> </div>
{% endif %} {% endif %}
{% if partner_sub %}<div>Premium subscription managed by {{ partner_name }}.</div>{% endif %} {% if partner_sub %}
{% if partner_sub.lifetime %}
<div>Premium lifetime subscription managed by {{ partner_name }}.</div>
{% else %}
<div>Premium subscription managed by {{ partner_name }}.</div>
{% endif %}
{% endif %}
{% elif current_user.in_trial() %} {% elif current_user.in_trial() %}
Your Premium trial expires {{ current_user.trial_end | dt }}. Your Premium trial expires {{ current_user.trial_end | dt }}.
{% else %} {% else %}

View File

@ -36,6 +36,24 @@ def test_delete_mailbox_transfer_mailbox_primary(flask_client):
assert str(mails_sent[0].msg).find("alias have been transferred") > -1 assert str(mails_sent[0].msg).find("alias have been transferred") > -1
@mail_sender.store_emails_test_decorator
def test_delete_mailbox_no_email(flask_client):
user = create_new_user()
m1 = Mailbox.create(
user_id=user.id, email=random_email(), verified=True, flush=True
)
job = Job.create(
name=JOB_DELETE_MAILBOX,
payload={"mailbox_id": m1.id, "transfer_mailbox_id": None, "send_mail": False},
run_at=arrow.now(),
commit=True,
)
Session.commit()
delete_mailbox_job(job)
mails_sent = mail_sender.get_stored_emails()
assert len(mails_sent) == 0
@mail_sender.store_emails_test_decorator @mail_sender.store_emails_test_decorator
def test_delete_mailbox_transfer_mailbox_in_list(flask_client): def test_delete_mailbox_transfer_mailbox_in_list(flask_client):
user = create_new_user() user = create_new_user()

View File

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

View File

@ -0,0 +1,100 @@
import arrow
from app.account_linking import (
SLPlan,
SLPlanType,
set_plan_for_partner_user,
)
from app.db import Session
from app.models import User, PartnerUser, PartnerSubscription
from app.proton.utils import get_proton_partner
from app.utils import random_string
from tests.utils import random_email
partner_user_id: int = 0
def setup_module():
global partner_user_id
email = random_email()
external_id = random_string()
sl_user = User.create(email, commit=True)
partner_user_id = PartnerUser.create(
user_id=sl_user.id,
partner_id=get_proton_partner().id,
external_user_id=external_id,
partner_email=email,
commit=True,
).id
def setup_function(func):
Session.query(PartnerSubscription).delete()
def test_free_plan_removes_sub():
pu = PartnerUser.get(partner_user_id)
sub_id = PartnerSubscription.create(
partner_user_id=partner_user_id,
end_at=arrow.utcnow(),
lifetime=False,
commit=True,
).id
set_plan_for_partner_user(pu, plan=SLPlan(type=SLPlanType.Free, expiration=None))
assert PartnerSubscription.get(sub_id) is None
def test_premium_plan_updates_expiration():
pu = PartnerUser.get(partner_user_id)
sub_id = PartnerSubscription.create(
partner_user_id=partner_user_id,
end_at=arrow.utcnow(),
lifetime=False,
commit=True,
).id
new_expiration = arrow.utcnow().shift(days=+10)
set_plan_for_partner_user(
pu, plan=SLPlan(type=SLPlanType.Premium, expiration=new_expiration)
)
assert PartnerSubscription.get(sub_id).end_at == new_expiration
def test_premium_plan_creates_sub():
pu = PartnerUser.get(partner_user_id)
new_expiration = arrow.utcnow().shift(days=+10)
set_plan_for_partner_user(
pu, plan=SLPlan(type=SLPlanType.Premium, expiration=new_expiration)
)
assert (
PartnerSubscription.get_by(partner_user_id=partner_user_id).end_at
== new_expiration
)
def test_lifetime_creates_sub():
pu = PartnerUser.get(partner_user_id)
new_expiration = arrow.utcnow().shift(days=+10)
set_plan_for_partner_user(
pu, plan=SLPlan(type=SLPlanType.PremiumLifetime, expiration=new_expiration)
)
sub = PartnerSubscription.get_by(partner_user_id=partner_user_id)
assert sub is not None
assert sub.end_at is None
assert sub.lifetime
def test_lifetime_updates_sub():
pu = PartnerUser.get(partner_user_id)
sub_id = PartnerSubscription.create(
partner_user_id=partner_user_id,
end_at=arrow.utcnow(),
lifetime=False,
commit=True,
).id
set_plan_for_partner_user(
pu, plan=SLPlan(type=SLPlanType.PremiumLifetime, expiration=arrow.utcnow())
)
sub = PartnerSubscription.get(sub_id)
assert sub is not None
assert sub.end_at is None
assert sub.lifetime

View File

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

View File

@ -14,7 +14,6 @@ from app.email_utils import generate_verp_email
from app.mail_sender import mail_sender from app.mail_sender import mail_sender
from app.models import ( from app.models import (
Alias, Alias,
AuthorizedAddress,
IgnoredEmail, IgnoredEmail,
EmailLog, EmailLog,
Notification, Notification,
@ -24,35 +23,12 @@ from app.models import (
) )
from app.utils import random_string, canonicalize_email from app.utils import random_string, canonicalize_email
from email_handler import ( from email_handler import (
get_mailbox_from_mail_from,
should_ignore, should_ignore,
is_automatic_out_of_office, is_automatic_out_of_office,
) )
from tests.utils import load_eml_file, create_new_user, random_email from tests.utils import load_eml_file, create_new_user, random_email
def test_get_mailbox_from_mail_from(flask_client):
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
mb = get_mailbox_from_mail_from(user.email, alias)
assert mb.email == user.email
mb = get_mailbox_from_mail_from("unauthorized@gmail.com", alias)
assert mb is None
# authorized address
AuthorizedAddress.create(
user_id=user.id,
mailbox_id=user.default_mailbox_id,
email="unauthorized@gmail.com",
commit=True,
)
mb = get_mailbox_from_mail_from("unauthorized@gmail.com", alias)
assert mb.email == user.email
def test_should_ignore(flask_client): def test_should_ignore(flask_client):
assert should_ignore("mail_from", []) is False assert should_ignore("mail_from", []) is False

View File

@ -791,12 +791,21 @@ def test_parse_id_from_bounce():
assert parse_id_from_bounce("anything+1234+@local") == 1234 assert parse_id_from_bounce("anything+1234+@local") == 1234
def test_get_queue_id(): def test_get_queue_id_esmtps():
for id_type in ["SMTP", "ESMTP", "ESMTPA", "ESMTPS"]:
msg = email.message_from_string(
f"Received: from mail-wr1-x434.google.com (mail-wr1-x434.google.com [IPv6:2a00:1450:4864:20::434])\r\n\t(using TLSv1.3 with cipher TLS_AES_128_GCM_SHA256 (128/128 bits))\r\n\t(No client certificate requested)\r\n\tby mx1.simplelogin.co (Postfix) with {id_type} id 4FxQmw1DXdz2vK2\r\n\tfor <jglfdjgld@alias.com>; Fri, 4 Jun 2021 14:55:43 +0000 (UTC)"
)
assert get_queue_id(msg) == "4FxQmw1DXdz2vK2", f"Failed for {id_type}"
def test_get_queue_id_postfix():
msg = email.message_from_string( msg = email.message_from_string(
"Received: from mail-wr1-x434.google.com (mail-wr1-x434.google.com [IPv6:2a00:1450:4864:20::434])\r\n\t(using TLSv1.3 with cipher TLS_AES_128_GCM_SHA256 (128/128 bits))\r\n\t(No client certificate requested)\r\n\tby mx1.simplelogin.co (Postfix) with ESMTPS id 4FxQmw1DXdz2vK2\r\n\tfor <jglfdjgld@alias.com>; Fri, 4 Jun 2021 14:55:43 +0000 (UTC)" "Received: by mailin001.somewhere.net (Postfix)\r\n\tid 4Xz5pb2nMszGrqpL; Wed, 27 Nov 2024 17:21:59 +0000 (UTC)'] by mailin001.somewhere.net (Postfix)"
) )
assert get_queue_id(msg) == "4FxQmw1DXdz2vK2" assert get_queue_id(msg) == "4Xz5pb2nMszGrqpL"
def test_get_queue_id_from_double_header(): def test_get_queue_id_from_double_header():

View File

@ -6,9 +6,18 @@ import pytest
from app import mailbox_utils, config from app import mailbox_utils, config
from app.db import Session from app.db import Session
from app.mail_sender import mail_sender from app.mail_sender import mail_sender
from app.mailbox_utils import MailboxEmailChangeError from app.mailbox_utils import MailboxEmailChangeError, get_mailbox_for_reply_phase
from app.models import Mailbox, MailboxActivation, User, Job, UserAuditLog from app.models import (
Mailbox,
MailboxActivation,
User,
Job,
UserAuditLog,
Alias,
AuthorizedAddress,
)
from app.user_audit_log_utils import UserAuditLogAction from app.user_audit_log_utils import UserAuditLogAction
from app.utils import random_string, canonicalize_email
from tests.utils import create_new_user, random_email from tests.utils import create_new_user, random_email
@ -50,6 +59,14 @@ def test_already_used():
mailbox_utils.create_mailbox(user, user.email) mailbox_utils.create_mailbox(user, user.email)
def test_already_used_with_different_case():
user.lifetime = True
email = random_email()
mailbox_utils.create_mailbox(user, email)
with pytest.raises(mailbox_utils.MailboxError):
mailbox_utils.create_mailbox(user, email.upper())
@mail_sender.store_emails_test_decorator @mail_sender.store_emails_test_decorator
def test_create_mailbox(): def test_create_mailbox():
email = random_email() email = random_email()
@ -418,3 +435,75 @@ def test_perform_mailbox_email_change_success():
user_id=user.id, action=UserAuditLogAction.UpdateMailbox.value user_id=user.id, action=UserAuditLogAction.UpdateMailbox.value
).count() ).count()
assert audit_log_entries == 1 assert audit_log_entries == 1
def test_get_mailbox_from_mail_from(flask_client):
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
mb = get_mailbox_for_reply_phase(user.email, "", alias)
assert mb.email == user.email
mb = get_mailbox_for_reply_phase("unauthorized@gmail.com", "", alias)
assert mb is None
# authorized address
AuthorizedAddress.create(
user_id=user.id,
mailbox_id=user.default_mailbox_id,
email="unauthorized@gmail.com",
commit=True,
)
mb = get_mailbox_for_reply_phase("unauthorized@gmail.com", "", alias)
assert mb.email == user.email
def test_get_mailbox_from_mail_from_for_canonical_email(flask_client):
prefix = random_string(10)
email = f"{prefix}+subaddresxs@gmail.com"
canonical_email = canonicalize_email(email)
assert canonical_email != email
user = create_new_user()
mbox = Mailbox.create(
email=canonical_email, user_id=user.id, verified=True, flush=True
)
alias = Alias.create(user_id=user.id, email=random_email(), mailbox_id=mbox.id)
Session.flush()
mb = get_mailbox_for_reply_phase(email, "", alias)
assert mb.email == canonical_email
mb = get_mailbox_for_reply_phase(canonical_email, "", alias)
assert mb.email == canonical_email
def test_get_mailbox_from_mail_from_coming_from_header_if_domain_is_aligned(
flask_client,
):
domain = f"{random_string(10)}.com"
envelope_from = f"envelope_verp@{domain}"
mail_from = f"mail_from@{domain}"
user = create_new_user()
mbox = Mailbox.create(email=mail_from, user_id=user.id, verified=True, flush=True)
alias = Alias.create(user_id=user.id, email=random_email(), mailbox_id=mbox.id)
Session.flush()
mb = get_mailbox_for_reply_phase(envelope_from, mail_from, alias)
assert mb.email == mail_from
def test_get_mailbox_from_mail_from_coming_from_header_if_domain_is_not_aligned(
flask_client,
):
domain = f"{random_string(10)}.com"
envelope_from = f"envelope_verp@{domain}"
mail_from = f"mail_from@other_{domain}"
user = create_new_user()
mbox = Mailbox.create(email=mail_from, user_id=user.id, verified=True, flush=True)
alias = Alias.create(user_id=user.id, email=random_email(), mailbox_id=mbox.id)
Session.flush()
mb = get_mailbox_for_reply_phase(envelope_from, mail_from, alias)
assert mb is None