Compare commits

...

12 Commits

Author SHA1 Message Date
9fd2fa9a78 4.61.1
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m41s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 4m6s
Build-Release-Image / Merge-Images (push) Successful in 18s
Build-Release-Image / Create-Release (push) Successful in 11s
Build-Release-Image / Notify (push) Successful in 3s
2024-11-30 12:00:10 +00:00
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
a62b43b7c4 4.56.1
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m30s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m37s
Build-Release-Image / Merge-Images (push) Successful in 22s
Build-Release-Image / Create-Release (push) Successful in 24s
Build-Release-Image / Notify (push) Successful in 3s
2024-10-25 12:00:05 +01:00
44fda2d94e 4.56.0
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m24s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m34s
Build-Release-Image / Merge-Images (push) Successful in 14s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 3s
2024-10-24 12:00:05 +01:00
bc48198bb1 4.55.1
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m28s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m31s
Build-Release-Image / Merge-Images (push) Successful in 16s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 4s
2024-10-19 12:00:05 +01:00
da6e56c4eb 4.55.0
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m43s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m10s
Build-Release-Image / Merge-Images (push) Successful in 27s
Build-Release-Image / Create-Release (push) Successful in 10s
Build-Release-Image / Notify (push) Successful in 3s
2024-10-18 12:00:06 +01:00
101 changed files with 3611 additions and 1105 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,18 @@ 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.utils import sanitize_email, canonicalize_email from app.utils import sanitize_email, canonicalize_email
from app.errors import ( from app.errors import (
AccountAlreadyLinkedToAnotherPartnerException, AccountAlreadyLinkedToAnotherPartnerException,
@ -23,12 +29,14 @@ from app.models import (
User, User,
Alias, Alias,
) )
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import random_string from app.utils import random_string
class SLPlanType(Enum): class SLPlanType(Enum):
Free = 1 Free = 1
Premium = 2 Premium = 2
PremiumLifetime = 3
@dataclass @dataclass
@ -52,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(
@ -62,24 +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}] with {end_time} / {is_lifetime}"
) )
PartnerSubscription.create( create_partner_subscription(
partner_user_id=partner_user.id, partner_user=partner_user,
end_at=plan.expiration, expiration=end_time,
lifetime=is_lifetime,
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(
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
LOG.i(
f"Updating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}] to {sub.end_at} / {sub.lifetime} "
)
emit_user_audit_log(
user=partner_user.user,
action=UserAuditLogAction.SubscriptionExtended,
message="Extended partner subscription",
)
Session.flush()
send_user_plan_changed_event(partner_user)
Session.commit() Session.commit()
@ -98,12 +135,13 @@ def ensure_partner_user_exists_for_user(
if res and res.partner_id != partner.id: if res and res.partner_id != partner.id:
raise AccountAlreadyLinkedToAnotherPartnerException() raise AccountAlreadyLinkedToAnotherPartnerException()
if not res: if not res:
res = PartnerUser.create( res = create_partner_user(
user_id=sl_user.id, user=sl_user,
partner_id=partner.id, partner_id=partner.id,
partner_email=link_request.email, partner_email=link_request.email,
external_user_id=link_request.external_user_id, external_user_id=link_request.external_user_id,
) )
Session.commit() Session.commit()
LOG.i( LOG.i(
f"Created new partner_user for partner:{partner.id} user:{sl_user.id} external_user_id:{link_request.external_user_id}. PartnerUser.id is {res.id}" f"Created new partner_user for partner:{partner.id} user:{sl_user.id} external_user_id:{link_request.external_user_id}. PartnerUser.id is {res.id}"
@ -131,17 +169,58 @@ 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,
) )
partner_user = PartnerUser.create( if partner_user is None:
user_id=new_user.id, # Get the user by canonical email and if not by normal email
user = User.get_by(email=canonical_email) or User.get_by(
email=self.link_request.email
)
if not user:
raise RuntimeError(
"Tried to create only partner on UniqueViolation but cannot find the user"
)
partner_user = self.create_partner_user(user)
Session.commit()
return LinkResult(
user=partner_user.user, strategy=ExistingUnlinkedUserStrategy.__name__
)
def create_partner_user(self, new_user: User):
partner_user = create_partner_user(
user=new_user,
partner_id=self.partner.id, partner_id=self.partner.id,
external_user_id=self.link_request.external_user_id, external_user_id=self.link_request.external_user_id,
partner_email=self.link_request.email, partner_email=self.link_request.email,
@ -153,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):
@ -200,7 +269,7 @@ def get_login_strategy(
return ExistingUnlinkedUserStrategy(link_request, user, partner) return ExistingUnlinkedUserStrategy(link_request, user, partner)
def check_alias(email: str) -> bool: def check_alias(email: str):
alias = Alias.get_by(email=email) alias = Alias.get_by(email=email)
if alias is not None: if alias is not None:
raise AccountIsUsingAliasAsEmail() raise AccountIsUsingAliasAsEmail()
@ -275,10 +344,26 @@ def switch_already_linked_user(
LOG.i( LOG.i(
f"Deleting previous partner_user:{other_partner_user.id} from user:{current_user.id}" f"Deleting previous partner_user:{other_partner_user.id} from user:{current_user.id}"
) )
emit_user_audit_log(
user=other_partner_user.user,
action=UserAuditLogAction.UnlinkAccount,
message=f"Deleting partner_user {other_partner_user.id} (external_user_id={other_partner_user.external_user_id} | partner_email={other_partner_user.partner_email}) from user {current_user.id}, as we received a new link request for the same partner",
)
PartnerUser.delete(other_partner_user.id) PartnerUser.delete(other_partner_user.id)
LOG.i(f"Linking partner_user:{partner_user.id} to user:{current_user.id}") LOG.i(f"Linking partner_user:{partner_user.id} to user:{current_user.id}")
# Link this partner_user to the current user # Link this partner_user to the current user
emit_user_audit_log(
user=partner_user.user,
action=UserAuditLogAction.UnlinkAccount,
message=f"Unlinking from partner, as user will now be tied to another external account. old=(id={partner_user.user.id} | email={partner_user.user.email}) | new=(id={current_user.id} | email={current_user.email})",
)
partner_user.user_id = current_user.id partner_user.user_id = current_user.id
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.LinkAccount,
message=f"Linking user {current_user.id} ({current_user.email}) to partner_user:{partner_user.id} (external_user_id={partner_user.external_user_id} | partner_email={partner_user.partner_email})",
)
# Set plan # Set plan
set_plan_for_partner_user(partner_user, link_request.plan) set_plan_for_partner_user(partner_user, link_request.plan)
Session.commit() Session.commit()

View File

@ -1,5 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional from typing import Optional, List
import arrow import arrow
import sqlalchemy import sqlalchemy
@ -16,6 +16,8 @@ from flask_admin.contrib import sqla
from flask_login import current_user from flask_login import current_user
from app.db import Session from app.db import Session
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import EventContent, UserPlanChanged
from app.models import ( from app.models import (
User, User,
ManualSubscription, ManualSubscription,
@ -35,8 +37,11 @@ from app.models import (
DomainDeletedAlias, DomainDeletedAlias,
PartnerUser, PartnerUser,
AliasMailbox, AliasMailbox,
AliasAuditLog,
UserAuditLog,
) )
from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
def _admin_action_formatter(view, context, model, name): def _admin_action_formatter(view, context, model, name):
@ -113,7 +118,7 @@ class SLAdminIndexView(AdminIndexView):
if not current_user.is_authenticated or not current_user.is_admin: if not current_user.is_authenticated or not current_user.is_admin:
return redirect(url_for("auth.login", next=request.url)) return redirect(url_for("auth.login", next=request.url))
return redirect("/admin/user") return redirect("/admin/email_search")
class UserAdmin(SLModelView): class UserAdmin(SLModelView):
@ -349,17 +354,42 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
manual_sub.end_at = manual_sub.end_at.shift(years=1) manual_sub.end_at = manual_sub.end_at.shift(years=1)
else: else:
manual_sub.end_at = arrow.now().shift(years=1, days=1) manual_sub.end_at = arrow.now().shift(years=1, days=1)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} extended manual subscription to user {user.email}",
)
EventDispatcher.send_event(
user=user,
content=EventContent(
user_plan_change=UserPlanChanged(
plan_end_time=manual_sub.end_at.timestamp
)
),
)
flash(f"Subscription extended to {manual_sub.end_at.humanize()}", "success") flash(f"Subscription extended to {manual_sub.end_at.humanize()}", "success")
continue else:
emit_user_audit_log(
user=user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} created manual subscription to user {user.email}",
)
manual_sub = ManualSubscription.create(
user_id=user.id,
end_at=arrow.now().shift(years=1, days=1),
comment=way,
is_giveaway=is_giveaway,
)
EventDispatcher.send_event(
user=user,
content=EventContent(
user_plan_change=UserPlanChanged(
plan_end_time=manual_sub.end_at.timestamp
)
),
)
ManualSubscription.create( flash(f"New {way} manual subscription for {user} is created", "success")
user_id=user.id,
end_at=arrow.now().shift(years=1, days=1),
comment=way,
is_giveaway=is_giveaway,
)
flash(f"New {way} manual subscription for {user} is created", "success")
Session.commit() Session.commit()
@ -451,14 +481,7 @@ class ManualSubscriptionAdmin(SLModelView):
"Extend 1 year more?", "Extend 1 year more?",
) )
def extend_1y(self, ids): def extend_1y(self, ids):
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)): self.__extend_manual_subscription(ids, msg="1 year", years=1)
ms.end_at = ms.end_at.shift(years=1)
flash(f"Extend subscription for 1 year for {ms.user}", "success")
AdminAuditLog.extend_subscription(
current_user.id, ms.user.id, ms.end_at, "1 year"
)
Session.commit()
@action( @action(
"extend_1m", "extend_1m",
@ -466,11 +489,26 @@ class ManualSubscriptionAdmin(SLModelView):
"Extend 1 month more?", "Extend 1 month more?",
) )
def extend_1m(self, ids): def extend_1m(self, ids):
self.__extend_manual_subscription(ids, msg="1 month", months=1)
def __extend_manual_subscription(self, ids: List[int], msg: str, **kwargs):
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)): for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)):
ms.end_at = ms.end_at.shift(months=1) sub: ManualSubscription = ms
flash(f"Extend subscription for 1 month for {ms.user}", "success") sub.end_at = sub.end_at.shift(**kwargs)
flash(f"Extend subscription for {msg} for {sub.user}", "success")
emit_user_audit_log(
user=sub.user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} extended manual subscription for {msg} for {sub.user}",
)
AdminAuditLog.extend_subscription( AdminAuditLog.extend_subscription(
current_user.id, ms.user.id, ms.end_at, "1 month" current_user.id, sub.user.id, sub.end_at, msg
)
EventDispatcher.send_event(
user=sub.user,
content=EventContent(
user_plan_change=UserPlanChanged(plan_end_time=sub.end_at.timestamp)
),
) )
Session.commit() Session.commit()
@ -737,22 +775,47 @@ class InvalidMailboxDomainAdmin(SLModelView):
class EmailSearchResult: class EmailSearchResult:
no_match: bool = True no_match: bool = True
alias: Optional[Alias] = None alias: Optional[Alias] = None
mailbox: list[Mailbox] = [] alias_audit_log: Optional[List[AliasAuditLog]] = None
mailbox: List[Mailbox] = []
mailbox_count: int = 0 mailbox_count: int = 0
deleted_alias: Optional[DeletedAlias] = None deleted_alias: Optional[DeletedAlias] = None
deleted_custom_alias: Optional[DomainDeletedAlias] = None deleted_alias_audit_log: Optional[List[AliasAuditLog]] = None
domain_deleted_alias: Optional[DomainDeletedAlias] = None
domain_deleted_alias_audit_log: Optional[List[AliasAuditLog]] = None
user: Optional[User] = None user: Optional[User] = None
user_audit_log: Optional[List[UserAuditLog]] = None
query: str
@staticmethod @staticmethod
def from_email(email: str) -> EmailSearchResult: def from_email(email: str) -> EmailSearchResult:
output = EmailSearchResult() output = EmailSearchResult()
output.query = email
alias = Alias.get_by(email=email) alias = Alias.get_by(email=email)
if alias: if alias:
output.alias = alias output.alias = alias
output.alias_audit_log = (
AliasAuditLog.filter_by(alias_id=alias.id)
.order_by(AliasAuditLog.created_at.desc())
.all()
)
output.no_match = False output.no_match = False
user = User.get_by(email=email) user = User.get_by(email=email)
if user: if user:
output.user = user output.user = user
output.user_audit_log = (
UserAuditLog.filter_by(user_id=user.id)
.order_by(UserAuditLog.created_at.desc())
.all()
)
output.no_match = False
user_audit_log = (
UserAuditLog.filter_by(user_email=email)
.order_by(UserAuditLog.created_at.desc())
.all()
)
if user_audit_log:
output.user_audit_log = user_audit_log
output.no_match = False output.no_match = False
mailboxes = ( mailboxes = (
Mailbox.filter_by(email=email).order_by(Mailbox.id.desc()).limit(10).all() Mailbox.filter_by(email=email).order_by(Mailbox.id.desc()).limit(10).all()
@ -764,10 +827,20 @@ class EmailSearchResult:
deleted_alias = DeletedAlias.get_by(email=email) deleted_alias = DeletedAlias.get_by(email=email)
if deleted_alias: if deleted_alias:
output.deleted_alias = deleted_alias output.deleted_alias = deleted_alias
output.deleted_alias_audit_log = (
AliasAuditLog.filter_by(alias_email=deleted_alias.email)
.order_by(AliasAuditLog.created_at.desc())
.all()
)
output.no_match = False output.no_match = False
domain_deleted_alias = DomainDeletedAlias.get_by(email=email) domain_deleted_alias = DomainDeletedAlias.get_by(email=email)
if domain_deleted_alias: if domain_deleted_alias:
output.domain_deleted_alias = domain_deleted_alias output.domain_deleted_alias = domain_deleted_alias
output.domain_deleted_alias_audit_log = (
AliasAuditLog.filter_by(alias_email=domain_deleted_alias.email)
.order_by(AliasAuditLog.created_at.desc())
.all()
)
output.no_match = False output.no_match = False
return output return output

View File

@ -0,0 +1,38 @@
from enum import Enum
from typing import Optional
from app.models import Alias, AliasAuditLog
class AliasAuditLogAction(Enum):
CreateAlias = "create"
ChangeAliasStatus = "change_status"
DeleteAlias = "delete"
UpdateAlias = "update"
InitiateTransferAlias = "initiate_transfer_alias"
AcceptTransferAlias = "accept_transfer_alias"
TransferredAlias = "transferred_alias"
ChangedMailboxes = "changed_mailboxes"
CreateContact = "create_contact"
UpdateContact = "update_contact"
DeleteContact = "delete_contact"
def emit_alias_audit_log(
alias: Alias,
action: AliasAuditLogAction,
message: str,
user_id: Optional[int] = None,
commit: bool = False,
):
AliasAuditLog.create(
user_id=user_id or alias.user_id,
alias_id=alias.id,
alias_email=alias.email,
action=action.value,
message=message,
commit=commit,
)

View File

@ -0,0 +1,61 @@
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional
from app.alias_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction
from app.db import Session
from app.models import Alias, AliasMailbox, Mailbox
_MAX_MAILBOXES_PER_ALIAS = 20
class CannotSetMailboxesForAliasCause(Enum):
Forbidden = "Forbidden"
EmptyMailboxes = "Must choose at least one mailbox"
TooManyMailboxes = "Too many mailboxes"
@dataclass
class SetMailboxesForAliasResult:
performed_change: bool
reason: Optional[CannotSetMailboxesForAliasCause]
def set_mailboxes_for_alias(
user_id: int, alias: Alias, mailbox_ids: List[int]
) -> Optional[CannotSetMailboxesForAliasCause]:
if len(mailbox_ids) == 0:
return CannotSetMailboxesForAliasCause.EmptyMailboxes
if len(mailbox_ids) > _MAX_MAILBOXES_PER_ALIAS:
return CannotSetMailboxesForAliasCause.TooManyMailboxes
mailboxes = (
Session.query(Mailbox)
.filter(
Mailbox.id.in_(mailbox_ids),
Mailbox.user_id == user_id,
Mailbox.verified == True, # noqa: E712
)
.all()
)
if len(mailboxes) != len(mailbox_ids):
return CannotSetMailboxesForAliasCause.Forbidden
# first remove all existing alias-mailboxes links
AliasMailbox.filter_by(alias_id=alias.id).delete()
Session.flush()
# then add all new mailboxes, being the first the one associated with the alias
for i, mailbox in enumerate(mailboxes):
if i == 0:
alias.mailbox_id = mailboxes[0].id
else:
AliasMailbox.create(alias_id=alias.id, mailbox_id=mailbox.id)
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.ChangedMailboxes,
message=",".join([f"{mailbox.id} ({mailbox.email})" for mailbox in mailboxes]),
)
return None

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

@ -8,6 +8,7 @@ from email_validator import validate_email, EmailNotValidError
from sqlalchemy.exc import IntegrityError, DataError from sqlalchemy.exc import IntegrityError, DataError
from flask import make_response from flask import make_response
from app.alias_audit_log_utils import AliasAuditLogAction, emit_alias_audit_log
from app.config import ( from app.config import (
BOUNCE_PREFIX_FOR_REPLY_PHASE, BOUNCE_PREFIX_FOR_REPLY_PHASE,
BOUNCE_PREFIX, BOUNCE_PREFIX,
@ -368,6 +369,10 @@ def delete_alias(
alias_id = alias.id alias_id = alias.id
alias_email = alias.email alias_email = alias.email
emit_alias_audit_log(
alias, AliasAuditLogAction.DeleteAlias, "Alias deleted by user action"
)
Alias.filter(Alias.id == alias.id).delete() Alias.filter(Alias.id == alias.id).delete()
Session.commit() Session.commit()
@ -450,7 +455,7 @@ def alias_export_csv(user, csv_direct_export=False):
return output return output
def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]): def transfer_alias(alias: Alias, new_user: User, new_mailboxes: [Mailbox]):
# cannot transfer alias which is used for receiving newsletter # cannot transfer alias which is used for receiving newsletter
if User.get_by(newsletter_alias_id=alias.id): if User.get_by(newsletter_alias_id=alias.id):
raise Exception("Cannot transfer alias that's used to receive newsletter") raise Exception("Cannot transfer alias that's used to receive newsletter")
@ -504,6 +509,12 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
alias.disable_pgp = False alias.disable_pgp = False
alias.pinned = False alias.pinned = False
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.TransferredAlias,
message=f"Lost ownership of alias due to alias transfer confirmed. New owner is {new_user.id}",
user_id=old_user.id,
)
EventDispatcher.send_event( EventDispatcher.send_event(
old_user, old_user,
EventContent( EventContent(
@ -513,6 +524,13 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
) )
), ),
) )
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.AcceptTransferAlias,
message=f"Accepted alias transfer from user {old_user.id}",
user_id=new_user.id,
)
EventDispatcher.send_event( EventDispatcher.send_event(
new_user, new_user,
EventContent( EventContent(
@ -529,7 +547,9 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
Session.commit() Session.commit()
def change_alias_status(alias: Alias, enabled: bool, commit: bool = False): def change_alias_status(
alias: Alias, enabled: bool, message: Optional[str] = None, commit: bool = False
):
LOG.i(f"Changing alias {alias} enabled to {enabled}") LOG.i(f"Changing alias {alias} enabled to {enabled}")
alias.enabled = enabled alias.enabled = enabled
@ -540,6 +560,12 @@ def change_alias_status(alias: Alias, enabled: bool, commit: bool = False):
created_at=int(alias.created_at.timestamp), created_at=int(alias.created_at.timestamp),
) )
EventDispatcher.send_event(alias.user, EventContent(alias_status_change=event)) EventDispatcher.send_event(alias.user, EventContent(alias_status_change=event))
audit_log_message = f"Set alias status to {enabled}"
if message is not None:
audit_log_message += f". {message}"
emit_alias_audit_log(
alias, AliasAuditLogAction.ChangeAliasStatus, audit_log_message
)
if commit: if commit:
Session.commit() Session.commit()

View File

@ -1,9 +1,13 @@
from typing import Optional
from deprecated import deprecated from deprecated import deprecated
from flask import g from flask import g
from flask import jsonify from flask import jsonify
from flask import request from flask import request
from app import alias_utils from app import alias_utils
from app.alias_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction
from app.alias_mailbox_utils import set_mailboxes_for_alias
from app.api.base import api_bp, require_api_auth from app.api.base import api_bp, require_api_auth
from app.api.serializer import ( from app.api.serializer import (
AliasInfo, AliasInfo,
@ -26,7 +30,7 @@ from app.errors import (
) )
from app.extensions import limiter from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import Alias, Contact, Mailbox, AliasMailbox, AliasDeleteReason from app.models import Alias, Contact, Mailbox, AliasDeleteReason
@deprecated @deprecated
@ -185,7 +189,11 @@ def toggle_alias(alias_id):
if not alias or alias.user_id != user.id: if not alias or alias.user_id != user.id:
return jsonify(error="Forbidden"), 403 return jsonify(error="Forbidden"), 403
alias_utils.change_alias_status(alias, enabled=not alias.enabled) alias_utils.change_alias_status(
alias,
enabled=not alias.enabled,
message=f"Set enabled={not alias.enabled} via API",
)
LOG.i(f"User {user} changed alias {alias} enabled status to {alias.enabled}") LOG.i(f"User {user} changed alias {alias} enabled status to {alias.enabled}")
Session.commit() Session.commit()
@ -272,10 +280,12 @@ def update_alias(alias_id):
if not alias or alias.user_id != user.id: if not alias or alias.user_id != user.id:
return jsonify(error="Forbidden"), 403 return jsonify(error="Forbidden"), 403
changed_fields = []
changed = False changed = False
if "note" in data: if "note" in data:
new_note = data.get("note") new_note = data.get("note")
alias.note = new_note alias.note = new_note
changed_fields.append("note")
changed = True changed = True
if "mailbox_id" in data: if "mailbox_id" in data:
@ -285,35 +295,19 @@ def update_alias(alias_id):
return jsonify(error="Forbidden"), 400 return jsonify(error="Forbidden"), 400
alias.mailbox_id = mailbox_id alias.mailbox_id = mailbox_id
changed_fields.append(f"mailbox_id ({mailbox_id})")
changed = True changed = True
if "mailbox_ids" in data: if "mailbox_ids" in data:
mailbox_ids = [int(m_id) for m_id in data.get("mailbox_ids")] mailbox_ids = [int(m_id) for m_id in data.get("mailbox_ids")]
mailboxes: [Mailbox] = [] err = set_mailboxes_for_alias(
user_id=user.id, alias=alias, mailbox_ids=mailbox_ids
# check if all mailboxes belong to user )
for mailbox_id in mailbox_ids: if err:
mailbox = Mailbox.get(mailbox_id) return jsonify(error=err.value), 400
if not mailbox or mailbox.user_id != user.id or not mailbox.verified:
return jsonify(error="Forbidden"), 400
mailboxes.append(mailbox)
if not mailboxes:
return jsonify(error="Must choose at least one mailbox"), 400
# <<< update alias mailboxes >>>
# first remove all existing alias-mailboxes links
AliasMailbox.filter_by(alias_id=alias.id).delete()
Session.flush()
# then add all new mailboxes
for i, mailbox in enumerate(mailboxes):
if i == 0:
alias.mailbox_id = mailboxes[0].id
else:
AliasMailbox.create(alias_id=alias.id, mailbox_id=mailbox.id)
# <<< END update alias mailboxes >>>
mailbox_ids_string = ",".join(map(str, mailbox_ids))
changed_fields.append(f"mailbox_ids ({mailbox_ids_string})")
changed = True changed = True
if "name" in data: if "name" in data:
@ -325,17 +319,26 @@ def update_alias(alias_id):
if new_name: if new_name:
new_name = new_name.replace("\n", "") new_name = new_name.replace("\n", "")
alias.name = new_name alias.name = new_name
changed_fields.append("name")
changed = True changed = True
if "disable_pgp" in data: if "disable_pgp" in data:
alias.disable_pgp = data.get("disable_pgp") alias.disable_pgp = data.get("disable_pgp")
changed_fields.append("disable_pgp")
changed = True changed = True
if "pinned" in data: if "pinned" in data:
alias.pinned = data.get("pinned") alias.pinned = data.get("pinned")
changed_fields.append("pinned")
changed = True changed = True
if changed: if changed:
changed_fields_string = ",".join(changed_fields)
emit_alias_audit_log(
alias,
AliasAuditLogAction.UpdateAlias,
f"Alias fields updated ({changed_fields_string})",
)
Session.commit() Session.commit()
return jsonify(ok=True), 200 return jsonify(ok=True), 200
@ -416,9 +419,8 @@ def create_contact_route(alias_id):
if not data: if not data:
return jsonify(error="request body cannot be empty"), 400 return jsonify(error="request body cannot be empty"), 400
alias: Alias = Alias.get(alias_id) alias: Optional[Alias] = Alias.get_by(id=alias_id, user_id=g.user.id)
if not alias:
if alias.user_id != g.user.id:
return jsonify(error="Forbidden"), 403 return jsonify(error="Forbidden"), 403
contact_address = data.get("contact") contact_address = data.get("contact")
@ -446,11 +448,16 @@ def delete_contact(contact_id):
200 200
""" """
user = g.user user = g.user
contact = Contact.get(contact_id) contact: Optional[Contact] = Contact.get(contact_id)
if not contact or contact.alias.user_id != user.id: if not contact or contact.alias.user_id != user.id:
return jsonify(error="Forbidden"), 403 return jsonify(error="Forbidden"), 403
emit_alias_audit_log(
alias=contact.alias,
action=AliasAuditLogAction.DeleteContact,
message=f"Deleted contact {contact_id} ({contact.email})",
)
Contact.delete(contact_id) Contact.delete(contact_id)
Session.commit() Session.commit()
@ -468,12 +475,17 @@ def toggle_contact(contact_id):
200 200
""" """
user = g.user user = g.user
contact = Contact.get(contact_id) contact: Optional[Contact] = Contact.get(contact_id)
if not contact or contact.alias.user_id != user.id: if not contact or contact.alias.user_id != user.id:
return jsonify(error="Forbidden"), 403 return jsonify(error="Forbidden"), 403
contact.block_forward = not contact.block_forward contact.block_forward = not contact.block_forward
emit_alias_audit_log(
alias=contact.alias,
action=AliasAuditLogAction.UpdateContact,
message=f"Set contact state {contact.id} {contact.email} -> {contact.website_email} to blocked {contact.block_forward}",
)
Session.commit() Session.commit()
return jsonify(block_forward=contact.block_forward), 200 return jsonify(block_forward=contact.block_forward), 200

View File

@ -23,6 +23,7 @@ from app.events.auth_event import LoginEvent, RegisterEvent
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, ApiKey, SocialAuth, AccountActivation from app.models import User, ApiKey, SocialAuth, AccountActivation
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import sanitize_email, canonicalize_email from app.utils import sanitize_email, canonicalize_email
@ -187,6 +188,11 @@ def auth_activate():
LOG.d("activate user %s", user) LOG.d("activate user %s", user)
user.activated = True user.activated = True
emit_user_audit_log(
user=user,
action=UserAuditLogAction.ActivateUser,
message=f"User has been activated: {user.email}",
)
AccountActivation.delete(account_activation.id) AccountActivation.delete(account_activation.id)
Session.commit() Session.commit()

View File

@ -2,8 +2,10 @@ from flask import g, request
from flask import jsonify from flask import jsonify
from app.api.base import api_bp, require_api_auth from app.api.base import api_bp, require_api_auth
from app.custom_domain_utils import set_custom_domain_mailboxes
from app.db import Session from app.db import Session
from app.models import CustomDomain, DomainDeletedAlias, Mailbox, DomainMailbox from app.log import LOG
from app.models import CustomDomain, DomainDeletedAlias
def custom_domain_to_dict(custom_domain: CustomDomain): def custom_domain_to_dict(custom_domain: CustomDomain):
@ -100,23 +102,14 @@ def update_custom_domain(custom_domain_id):
if "mailbox_ids" in data: if "mailbox_ids" in data:
mailbox_ids = [int(m_id) for m_id in data.get("mailbox_ids")] mailbox_ids = [int(m_id) for m_id in data.get("mailbox_ids")]
if mailbox_ids: result = set_custom_domain_mailboxes(user.id, custom_domain, mailbox_ids)
# check if mailbox is not tempered with if result.success:
mailboxes = []
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != user.id or not mailbox.verified:
return jsonify(error="Forbidden"), 400
mailboxes.append(mailbox)
# first remove all existing domain-mailboxes links
DomainMailbox.filter_by(domain_id=custom_domain.id).delete()
Session.flush()
for mailbox in mailboxes:
DomainMailbox.create(domain_id=custom_domain.id, mailbox_id=mailbox.id)
changed = True changed = True
else:
LOG.info(
f"Prevented from updating mailboxes [custom_domain_id={custom_domain.id}]: {result.reason.value}"
)
return jsonify(error="Forbidden"), 400
if changed: if changed:
Session.commit() Session.commit()

View File

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

View File

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

View File

@ -6,6 +6,7 @@ from app import config
from app.extensions import limiter from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import Job, ApiToCookieToken from app.models import Job, ApiToCookieToken
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
@api_bp.route("/user", methods=["DELETE"]) @api_bp.route("/user", methods=["DELETE"])
@ -16,6 +17,11 @@ def delete_user():
""" """
# Schedule delete account job # Schedule delete account job
emit_user_audit_log(
user=g.user,
action=UserAuditLogAction.UserMarkedForDeletion,
message=f"Marked user {g.user.id} ({g.user.email}) for deletion from API",
)
LOG.w("schedule delete account job for %s", g.user) LOG.w("schedule delete account job for %s", g.user)
Job.create( Job.create(
name=config.JOB_DELETE_ACCOUNT, name=config.JOB_DELETE_ACCOUNT,

View File

@ -7,6 +7,7 @@ from app.db import Session
from app.extensions import limiter from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import ActivationCode from app.models import ActivationCode
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import sanitize_next_url from app.utils import sanitize_next_url
@ -47,6 +48,11 @@ def activate():
user = activation_code.user user = activation_code.user
user.activated = True user.activated = True
emit_user_audit_log(
user=user,
action=UserAuditLogAction.ActivateUser,
message=f"User has been activated: {user.email}",
)
login_user(user) login_user(user)
# activation code is to be used only once # activation code is to be used only once

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

@ -9,6 +9,7 @@ from app.auth.views.login_utils import after_login
from app.db import Session from app.db import Session
from app.extensions import limiter from app.extensions import limiter
from app.models import ResetPasswordCode from app.models import ResetPasswordCode
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
class ResetPasswordForm(FlaskForm): class ResetPasswordForm(FlaskForm):
@ -59,6 +60,11 @@ def reset_password():
# this can be served to activate user too # this can be served to activate user too
user.activated = True user.activated = True
emit_user_audit_log(
user=user,
action=UserAuditLogAction.ResetPassword,
message="User has reset their password",
)
# remove all reset password codes # remove all reset password codes
ResetPasswordCode.filter_by(user_id=user.id).delete() ResetPasswordCode.filter_by(user_id=user.id).delete()

View File

@ -309,6 +309,7 @@ JOB_DELETE_DOMAIN = "delete-domain"
JOB_SEND_USER_REPORT = "send-user-report" JOB_SEND_USER_REPORT = "send-user-report"
JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1" JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1"
JOB_SEND_ALIAS_CREATION_EVENTS = "send-alias-creation-events" JOB_SEND_ALIAS_CREATION_EVENTS = "send-alias-creation-events"
JOB_SEND_EVENT_TO_WEBHOOK = "send-event-to-webhook"
# for pagination # for pagination
PAGE_LIMIT = 20 PAGE_LIMIT = 20
@ -601,7 +602,6 @@ SKIP_MX_LOOKUP_ON_CHECK = False
DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ
SUBSCRIPTION_CHANGE_WEBHOOK = os.environ.get("SUBSCRIPTION_CHANGE_WEBHOOK", None)
MAX_API_KEYS = int(os.environ.get("MAX_API_KEYS", 30)) MAX_API_KEYS = int(os.environ.get("MAX_API_KEYS", 30))
UPCLOUD_USERNAME = os.environ.get("UPCLOUD_USERNAME", None) UPCLOUD_USERNAME = os.environ.get("UPCLOUD_USERNAME", None)
@ -663,3 +663,5 @@ PARTNER_CUSTOM_DOMAIN_VALIDATION_PREFIXES: dict[int, str] = read_partner_dict(
MAILBOX_VERIFICATION_OVERRIDE_CODE: Optional[str] = os.environ.get( MAILBOX_VERIFICATION_OVERRIDE_CODE: Optional[str] = os.environ.get(
"MAILBOX_VERIFICATION_OVERRIDE_CODE", None "MAILBOX_VERIFICATION_OVERRIDE_CODE", None
) )
AUDIT_LOG_MAX_DAYS = int(os.environ.get("AUDIT_LOG_MAX_DAYS", 30))

View File

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

View File

@ -10,6 +10,7 @@ from app.db import Session
from app.email_utils import get_email_domain_part from app.email_utils import get_email_domain_part
from app.log import LOG from app.log import LOG
from app.models import User, CustomDomain, SLDomain, Mailbox, Job, DomainMailbox from app.models import User, CustomDomain, SLDomain, Mailbox, Job, DomainMailbox
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
_ALLOWED_DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$") _ALLOWED_DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$")
_MAX_MAILBOXES_PER_DOMAIN = 20 _MAX_MAILBOXES_PER_DOMAIN = 20
@ -137,6 +138,11 @@ def create_custom_domain(
if partner_id is not None: if partner_id is not None:
new_custom_domain.partner_id = partner_id new_custom_domain.partner_id = partner_id
emit_user_audit_log(
user=user,
action=UserAuditLogAction.CreateCustomDomain,
message=f"Created custom domain {new_custom_domain.id} ({new_domain})",
)
Session.commit() Session.commit()
return CreateCustomDomainResult( return CreateCustomDomainResult(
@ -190,5 +196,11 @@ def set_custom_domain_mailboxes(
for mailbox in mailboxes: for mailbox in mailboxes:
DomainMailbox.create(domain_id=custom_domain.id, mailbox_id=mailbox.id) DomainMailbox.create(domain_id=custom_domain.id, mailbox_id=mailbox.id)
mailboxes_as_str = ",".join(map(str, mailbox_ids))
emit_user_audit_log(
user=custom_domain.user,
action=UserAuditLogAction.UpdateCustomDomain,
message=f"Updated custom domain {custom_domain.id} mailboxes (domain={custom_domain.domain}) (mailboxes={mailboxes_as_str})",
)
Session.commit() Session.commit()
return SetCustomDomainMailboxesResult(success=True) return SetCustomDomainMailboxesResult(success=True)

View File

@ -11,6 +11,7 @@ from app.dns_utils import (
get_network_dns_client, get_network_dns_client,
) )
from app.models import CustomDomain from app.models import CustomDomain
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import random_string from app.utils import random_string
@ -121,6 +122,12 @@ class CustomDomainValidation:
# Original DKIM record is not there, which means the DKIM config is not finished. Proceed with the # Original DKIM record is not there, which means the DKIM config is not finished. Proceed with the
# rest of the code path, returning the invalid records and clearing the flag # rest of the code path, returning the invalid records and clearing the flag
custom_domain.dkim_verified = len(invalid_records) == 0 custom_domain.dkim_verified = len(invalid_records) == 0
if custom_domain.dkim_verified:
emit_user_audit_log(
user=custom_domain.user,
action=UserAuditLogAction.VerifyCustomDomain,
message=f"Verified DKIM records for custom domain {custom_domain.id} ({custom_domain.domain})",
)
Session.commit() Session.commit()
return invalid_records return invalid_records
@ -137,6 +144,11 @@ class CustomDomainValidation:
if expected_verification_record in txt_records: if expected_verification_record in txt_records:
custom_domain.ownership_verified = True custom_domain.ownership_verified = True
emit_user_audit_log(
user=custom_domain.user,
action=UserAuditLogAction.VerifyCustomDomain,
message=f"Verified ownership for custom domain {custom_domain.id} ({custom_domain.domain})",
)
Session.commit() Session.commit()
return DomainValidationResult(success=True, errors=[]) return DomainValidationResult(success=True, errors=[])
else: else:
@ -155,6 +167,11 @@ class CustomDomainValidation:
) )
else: else:
custom_domain.verified = True custom_domain.verified = True
emit_user_audit_log(
user=custom_domain.user,
action=UserAuditLogAction.VerifyCustomDomain,
message=f"Verified MX records for custom domain {custom_domain.id} ({custom_domain.domain})",
)
Session.commit() Session.commit()
return DomainValidationResult(success=True, errors=[]) return DomainValidationResult(success=True, errors=[])
@ -165,6 +182,11 @@ class CustomDomainValidation:
expected_spf_domain = self.get_expected_spf_domain(custom_domain) expected_spf_domain = self.get_expected_spf_domain(custom_domain)
if expected_spf_domain in spf_domains: if expected_spf_domain in spf_domains:
custom_domain.spf_verified = True custom_domain.spf_verified = True
emit_user_audit_log(
user=custom_domain.user,
action=UserAuditLogAction.VerifyCustomDomain,
message=f"Verified SPF records for custom domain {custom_domain.id} ({custom_domain.domain})",
)
Session.commit() Session.commit()
return DomainValidationResult(success=True, errors=[]) return DomainValidationResult(success=True, errors=[])
else: else:
@ -183,6 +205,11 @@ class CustomDomainValidation:
txt_records = self._dns_client.get_txt_record("_dmarc." + custom_domain.domain) txt_records = self._dns_client.get_txt_record("_dmarc." + custom_domain.domain)
if DMARC_RECORD in txt_records: if DMARC_RECORD in txt_records:
custom_domain.dmarc_verified = True custom_domain.dmarc_verified = True
emit_user_audit_log(
user=custom_domain.user,
action=UserAuditLogAction.VerifyCustomDomain,
message=f"Verified DMARC records for custom domain {custom_domain.id} ({custom_domain.domain})",
)
Session.commit() Session.commit()
return DomainValidationResult(success=True, errors=[]) return DomainValidationResult(success=True, errors=[])
else: else:

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

@ -1,5 +1,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from operator import or_ from operator import or_
from typing import Optional
from flask import render_template, request, redirect, flash from flask import render_template, request, redirect, flash
from flask import url_for from flask import url_for
@ -10,6 +11,7 @@ from wtforms import StringField, validators, ValidationError
# Need to import directly from config to allow modification from the tests # Need to import directly from config to allow modification from the tests
from app import config, parallel_limiter, contact_utils from app import config, parallel_limiter, contact_utils
from app.alias_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction
from app.contact_utils import ContactCreateError from app.contact_utils import ContactCreateError
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
@ -190,7 +192,7 @@ def get_contact_infos(
def delete_contact(alias: Alias, contact_id: int): def delete_contact(alias: Alias, contact_id: int):
contact = Contact.get(contact_id) contact: Optional[Contact] = Contact.get(contact_id)
if not contact: if not contact:
flash("Unknown error. Refresh the page", "warning") flash("Unknown error. Refresh the page", "warning")
@ -198,6 +200,11 @@ def delete_contact(alias: Alias, contact_id: int):
flash("You cannot delete reverse-alias", "warning") flash("You cannot delete reverse-alias", "warning")
else: else:
delete_contact_email = contact.website_email delete_contact_email = contact.website_email
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.DeleteContact,
message=f"Delete contact {contact_id} ({contact.email})",
)
Contact.delete(contact_id) Contact.delete(contact_id)
Session.commit() Session.commit()
@ -220,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

@ -7,6 +7,7 @@ from flask import render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import config from app import config
from app.alias_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction
from app.alias_utils import transfer_alias from app.alias_utils import transfer_alias
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required from app.dashboard.views.enter_sudo import sudo_required
@ -57,6 +58,12 @@ def alias_transfer_send_route(alias_id):
transfer_token = f"{alias.id}.{secrets.token_urlsafe(32)}" transfer_token = f"{alias.id}.{secrets.token_urlsafe(32)}"
alias.transfer_token = hmac_alias_transfer_token(transfer_token) alias.transfer_token = hmac_alias_transfer_token(transfer_token)
alias.transfer_token_expiration = arrow.utcnow().shift(hours=24) alias.transfer_token_expiration = arrow.utcnow().shift(hours=24)
emit_alias_audit_log(
alias,
AliasAuditLogAction.InitiateTransferAlias,
"Initiated alias transfer",
)
Session.commit() Session.commit()
alias_transfer_url = ( alias_transfer_url = (
config.URL config.URL

View File

@ -1,8 +1,11 @@
from typing import Optional
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
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
from wtforms import StringField, validators from wtforms import StringField, validators
from app.alias_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction
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.models import Contact from app.models import Contact
@ -20,7 +23,7 @@ class PGPContactForm(FlaskForm):
@dashboard_bp.route("/contact/<int:contact_id>/", methods=["GET", "POST"]) @dashboard_bp.route("/contact/<int:contact_id>/", methods=["GET", "POST"])
@login_required @login_required
def contact_detail_route(contact_id): def contact_detail_route(contact_id):
contact = Contact.get(contact_id) contact: Optional[Contact] = Contact.get(contact_id)
if not contact or contact.user_id != current_user.id: if not contact or contact.user_id != current_user.id:
flash("You cannot see this page", "warning") flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
@ -50,6 +53,11 @@ def contact_detail_route(contact_id):
except PGPException: except PGPException:
flash("Cannot add the public key, please verify it", "error") flash("Cannot add the public key, please verify it", "error")
else: else:
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.UpdateContact,
message=f"Added PGP key {contact.pgp_public_key} for contact {contact_id} ({contact.email})",
)
Session.commit() Session.commit()
flash( flash(
f"PGP public key for {contact.email} is saved successfully", f"PGP public key for {contact.email} is saved successfully",
@ -62,6 +70,11 @@ def contact_detail_route(contact_id):
) )
elif pgp_form.action.data == "remove": elif pgp_form.action.data == "remove":
# Free user can decide to remove contact PGP key # Free user can decide to remove contact PGP key
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.UpdateContact,
message=f"Removed PGP key {contact.pgp_public_key} for contact {contact_id} ({contact.email})",
)
contact.pgp_public_key = None contact.pgp_public_key = None
contact.pgp_finger_print = None contact.pgp_finger_print = None
Session.commit() Session.commit()

View File

@ -8,6 +8,7 @@ from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required from app.dashboard.views.enter_sudo import sudo_required
from app.log import LOG from app.log import LOG
from app.models import Subscription, Job from app.models import Subscription, Job
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
class DeleteDirForm(FlaskForm): class DeleteDirForm(FlaskForm):
@ -33,6 +34,11 @@ def delete_account():
# Schedule delete account job # Schedule delete account job
LOG.w("schedule delete account job for %s", current_user) LOG.w("schedule delete account job for %s", current_user)
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UserMarkedForDeletion,
message=f"User {current_user.id} ({current_user.email}) marked for deletion via webapp",
)
Job.create( Job.create(
name=JOB_DELETE_ACCOUNT, name=JOB_DELETE_ACCOUNT,
payload={"user_id": current_user.id}, payload={"user_id": current_user.id},

View File

@ -1,3 +1,5 @@
from typing import Optional
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
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
@ -20,6 +22,7 @@ from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
from app.errors import DirectoryInTrashError from app.errors import DirectoryInTrashError
from app.models import Directory, Mailbox, DirectoryMailbox from app.models import Directory, Mailbox, DirectoryMailbox
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
class NewDirForm(FlaskForm): class NewDirForm(FlaskForm):
@ -69,7 +72,9 @@ def directory():
if not delete_dir_form.validate(): if not delete_dir_form.validate():
flash("Invalid request", "warning") flash("Invalid request", "warning")
return redirect(url_for("dashboard.directory")) return redirect(url_for("dashboard.directory"))
dir_obj = Directory.get(delete_dir_form.directory_id.data) dir_obj: Optional[Directory] = Directory.get(
delete_dir_form.directory_id.data
)
if not dir_obj: if not dir_obj:
flash("Unknown error. Refresh the page", "warning") flash("Unknown error. Refresh the page", "warning")
@ -79,6 +84,11 @@ def directory():
return redirect(url_for("dashboard.directory")) return redirect(url_for("dashboard.directory"))
name = dir_obj.name name = dir_obj.name
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.DeleteDirectory,
message=f"Delete directory {dir_obj.id} ({dir_obj.name})",
)
Directory.delete(dir_obj.id) Directory.delete(dir_obj.id)
Session.commit() Session.commit()
flash(f"Directory {name} has been deleted", "success") flash(f"Directory {name} has been deleted", "success")
@ -90,7 +100,7 @@ def directory():
flash("Invalid request", "warning") flash("Invalid request", "warning")
return redirect(url_for("dashboard.directory")) return redirect(url_for("dashboard.directory"))
dir_id = toggle_dir_form.directory_id.data dir_id = toggle_dir_form.directory_id.data
dir_obj = Directory.get(dir_id) dir_obj: Optional[Directory] = Directory.get(dir_id)
if not dir_obj or dir_obj.user_id != current_user.id: if not dir_obj or dir_obj.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning") flash("Unknown error. Refresh the page", "warning")
@ -103,6 +113,11 @@ def directory():
dir_obj.disabled = True dir_obj.disabled = True
flash(f"On-the-fly is disabled for {dir_obj.name}", "warning") flash(f"On-the-fly is disabled for {dir_obj.name}", "warning")
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateDirectory,
message=f"Updated directory {dir_obj.id} ({dir_obj.name}) set disabled = {dir_obj.disabled}",
)
Session.commit() Session.commit()
return redirect(url_for("dashboard.directory")) return redirect(url_for("dashboard.directory"))
@ -112,7 +127,7 @@ def directory():
flash("Invalid request", "warning") flash("Invalid request", "warning")
return redirect(url_for("dashboard.directory")) return redirect(url_for("dashboard.directory"))
dir_id = update_dir_form.directory_id.data dir_id = update_dir_form.directory_id.data
dir_obj = Directory.get(dir_id) dir_obj: Optional[Directory] = Directory.get(dir_id)
if not dir_obj or dir_obj.user_id != current_user.id: if not dir_obj or dir_obj.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning") flash("Unknown error. Refresh the page", "warning")
@ -143,6 +158,12 @@ def directory():
for mailbox in mailboxes: for mailbox in mailboxes:
DirectoryMailbox.create(directory_id=dir_obj.id, mailbox_id=mailbox.id) DirectoryMailbox.create(directory_id=dir_obj.id, mailbox_id=mailbox.id)
mailboxes_as_str = ",".join(map(str, mailbox_ids))
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateDirectory,
message=f"Updated directory {dir_obj.id} ({dir_obj.name}) mailboxes ({mailboxes_as_str})",
)
Session.commit() Session.commit()
flash(f"Directory {dir_obj.name} has been updated", "success") flash(f"Directory {dir_obj.name} has been updated", "success")
@ -181,6 +202,11 @@ def directory():
new_dir = Directory.create( new_dir = Directory.create(
name=new_dir_name, user_id=current_user.id name=new_dir_name, user_id=current_user.id
) )
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.CreateDirectory,
message=f"New directory {new_dir.name} ({new_dir.name})",
)
except DirectoryInTrashError: except DirectoryInTrashError:
flash( flash(
f"{new_dir_name} has been used before and cannot be reused", f"{new_dir_name} has been used before and cannot be reused",

View File

@ -20,6 +20,7 @@ from app.models import (
AutoCreateRuleMailbox, AutoCreateRuleMailbox,
) )
from app.regex_utils import regex_match from app.regex_utils import regex_match
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import random_string, CSRFValidationForm from app.utils import random_string, CSRFValidationForm
@ -164,6 +165,11 @@ def domain_detail(custom_domain_id):
return redirect(request.url) return redirect(request.url)
if request.form.get("form-name") == "switch-catch-all": if request.form.get("form-name") == "switch-catch-all":
custom_domain.catch_all = not custom_domain.catch_all custom_domain.catch_all = not custom_domain.catch_all
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateCustomDomain,
message=f"Switched custom domain {custom_domain.id} ({custom_domain.domain}) catch all to {custom_domain.catch_all}",
)
Session.commit() Session.commit()
if custom_domain.catch_all: if custom_domain.catch_all:
@ -182,6 +188,11 @@ def domain_detail(custom_domain_id):
elif request.form.get("form-name") == "set-name": elif request.form.get("form-name") == "set-name":
if request.form.get("action") == "save": if request.form.get("action") == "save":
custom_domain.name = request.form.get("alias-name").replace("\n", "") custom_domain.name = request.form.get("alias-name").replace("\n", "")
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateCustomDomain,
message=f"Switched custom domain {custom_domain.id} ({custom_domain.domain}) name",
)
Session.commit() Session.commit()
flash( flash(
f"Default alias name for Domain {custom_domain.domain} has been set", f"Default alias name for Domain {custom_domain.domain} has been set",
@ -189,6 +200,11 @@ def domain_detail(custom_domain_id):
) )
else: else:
custom_domain.name = None custom_domain.name = None
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateCustomDomain,
message=f"Cleared custom domain {custom_domain.id} ({custom_domain.domain}) name",
)
Session.commit() Session.commit()
flash( flash(
f"Default alias name for Domain {custom_domain.domain} has been removed", f"Default alias name for Domain {custom_domain.domain} has been removed",
@ -202,6 +218,11 @@ def domain_detail(custom_domain_id):
custom_domain.random_prefix_generation = ( custom_domain.random_prefix_generation = (
not custom_domain.random_prefix_generation not custom_domain.random_prefix_generation
) )
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateCustomDomain,
message=f"Switched custom domain {custom_domain.id} ({custom_domain.domain}) random prefix generation to {custom_domain.random_prefix_generation}",
)
Session.commit() Session.commit()
if custom_domain.random_prefix_generation: if custom_domain.random_prefix_generation:

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"):
@ -149,7 +152,9 @@ def index():
) )
flash(f"Alias {email} has been deleted", "success") flash(f"Alias {email} has been deleted", "success")
elif request.form.get("form-name") == "disable-alias": elif request.form.get("form-name") == "disable-alias":
alias_utils.change_alias_status(alias, enabled=False) alias_utils.change_alias_status(
alias, enabled=False, message="Set enabled=False from dashboard"
)
Session.commit() Session.commit()
flash(f"Alias {alias.email} has been disabled", "success") flash(f"Alias {alias.email} has been disabled", "success")

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

@ -1,6 +1,7 @@
import base64 import base64
import binascii import binascii
import json import json
from typing import Optional
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
@ -15,6 +16,7 @@ from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
from app.log import LOG from app.log import LOG
from app.models import Mailbox from app.models import Mailbox
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import CSRFValidationForm from app.utils import CSRFValidationForm
@ -119,10 +121,16 @@ def mailbox_route():
@login_required @login_required
def mailbox_verify(): def mailbox_verify():
mailbox_id = request.args.get("mailbox_id") mailbox_id = request.args.get("mailbox_id")
if not mailbox_id:
LOG.i("Missing mailbox_id")
flash("You followed an invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
code = request.args.get("code") code = request.args.get("code")
if not code: if not code:
# Old way # Old way
return verify_with_signed_secret(mailbox_id) return verify_with_signed_secret(mailbox_id)
try: try:
mailbox = mailbox_utils.verify_mailbox_code(current_user, mailbox_id, code) mailbox = mailbox_utils.verify_mailbox_code(current_user, mailbox_id, code)
except mailbox_utils.MailboxError as e: except mailbox_utils.MailboxError as e:
@ -151,7 +159,7 @@ def verify_with_signed_secret(request: str):
flash("Invalid link. Please delete and re-add your mailbox", "error") flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
mailbox_id = mailbox_data[0] mailbox_id = mailbox_data[0]
mailbox = Mailbox.get(mailbox_id) mailbox: Optional[Mailbox] = Mailbox.get(mailbox_id)
if not mailbox: if not mailbox:
flash("Invalid link", "error") flash("Invalid link", "error")
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
@ -161,6 +169,11 @@ def verify_with_signed_secret(request: str):
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
mailbox.verified = True mailbox.verified = True
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.VerifyMailbox,
message=f"Verified mailbox {mailbox.id} ({mailbox.email})",
)
Session.commit() Session.commit()
LOG.d("Mailbox %s is verified", mailbox) LOG.d("Mailbox %s is verified", mailbox)

View File

@ -16,10 +16,11 @@ from app.db import Session
from app.email_utils import email_can_be_used_as_mailbox from app.email_utils import email_can_be_used_as_mailbox
from app.email_utils import mailbox_already_used, render, send_email from app.email_utils import mailbox_already_used, render, send_email
from app.extensions import limiter from app.extensions import limiter
from app.log import LOG from app.mailbox_utils import perform_mailbox_email_change, MailboxEmailChangeError
from app.models import Alias, AuthorizedAddress from app.models import Alias, AuthorizedAddress
from app.models import Mailbox from app.models import Mailbox
from app.pgp_utils import PGPException, load_public_key_and_check from app.pgp_utils import PGPException, load_public_key_and_check
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import sanitize_email, CSRFValidationForm from app.utils import sanitize_email, CSRFValidationForm
@ -88,8 +89,12 @@ def mailbox_detail_route(mailbox_id):
flash("SPF enforcement globally not enabled", "error") flash("SPF enforcement globally not enabled", "error")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
mailbox.force_spf = ( force_spf_value = request.form.get("spf-status") == "on"
True if request.form.get("spf-status") == "on" else False mailbox.force_spf = force_spf_value
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Set force_spf to {force_spf_value} on mailbox {mailbox_id} ({mailbox.email})",
) )
Session.commit() Session.commit()
flash( flash(
@ -113,6 +118,11 @@ def mailbox_detail_route(mailbox_id):
if AuthorizedAddress.get_by(mailbox_id=mailbox.id, email=address): if AuthorizedAddress.get_by(mailbox_id=mailbox.id, email=address):
flash(f"{address} already added", "error") flash(f"{address} already added", "error")
else: else:
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Add authorized address {address} to mailbox {mailbox_id} ({mailbox.email})",
)
AuthorizedAddress.create( AuthorizedAddress.create(
user_id=current_user.id, user_id=current_user.id,
mailbox_id=mailbox.id, mailbox_id=mailbox.id,
@ -133,6 +143,11 @@ def mailbox_detail_route(mailbox_id):
flash("Unknown error. Refresh the page", "warning") flash("Unknown error. Refresh the page", "warning")
else: else:
address = authorized_address.email address = authorized_address.email
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Remove authorized address {address} from mailbox {mailbox_id} ({mailbox.email})",
)
AuthorizedAddress.delete(authorized_address_id) AuthorizedAddress.delete(authorized_address_id)
Session.commit() Session.commit()
flash(f"{address} has been deleted", "success") flash(f"{address} has been deleted", "success")
@ -165,6 +180,11 @@ def mailbox_detail_route(mailbox_id):
except PGPException: except PGPException:
flash("Cannot add the public key, please verify it", "error") flash("Cannot add the public key, please verify it", "error")
else: else:
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Add PGP Key {mailbox.pgp_finger_print} to mailbox {mailbox_id} ({mailbox.email})",
)
Session.commit() Session.commit()
flash("Your PGP public key is saved successfully", "success") flash("Your PGP public key is saved successfully", "success")
return redirect( return redirect(
@ -172,6 +192,11 @@ def mailbox_detail_route(mailbox_id):
) )
elif request.form.get("action") == "remove": elif request.form.get("action") == "remove":
# Free user can decide to remove their added PGP key # Free user can decide to remove their added PGP key
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Remove PGP Key {mailbox.pgp_finger_print} from mailbox {mailbox_id} ({mailbox.email})",
)
mailbox.pgp_public_key = None mailbox.pgp_public_key = None
mailbox.pgp_finger_print = None mailbox.pgp_finger_print = None
mailbox.disable_pgp = False mailbox.disable_pgp = False
@ -191,9 +216,19 @@ def mailbox_detail_route(mailbox_id):
) )
else: else:
mailbox.disable_pgp = False mailbox.disable_pgp = False
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Enabled PGP for mailbox {mailbox_id} ({mailbox.email})",
)
flash(f"PGP is enabled on {mailbox.email}", "info") flash(f"PGP is enabled on {mailbox.email}", "info")
else: else:
mailbox.disable_pgp = True mailbox.disable_pgp = True
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Disabled PGP for mailbox {mailbox_id} ({mailbox.email})",
)
flash(f"PGP is disabled on {mailbox.email}", "info") flash(f"PGP is disabled on {mailbox.email}", "info")
Session.commit() Session.commit()
@ -203,6 +238,11 @@ def mailbox_detail_route(mailbox_id):
elif request.form.get("form-name") == "generic-subject": elif request.form.get("form-name") == "generic-subject":
if request.form.get("action") == "save": if request.form.get("action") == "save":
mailbox.generic_subject = request.form.get("generic-subject") mailbox.generic_subject = request.form.get("generic-subject")
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Set generic subject for mailbox {mailbox_id} ({mailbox.email})",
)
Session.commit() Session.commit()
flash("Generic subject is enabled", "success") flash("Generic subject is enabled", "success")
return redirect( return redirect(
@ -210,6 +250,11 @@ def mailbox_detail_route(mailbox_id):
) )
elif request.form.get("action") == "remove": elif request.form.get("action") == "remove":
mailbox.generic_subject = None mailbox.generic_subject = None
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Remove generic subject for mailbox {mailbox_id} ({mailbox.email})",
)
Session.commit() Session.commit()
flash("Generic subject is disabled", "success") flash("Generic subject is disabled", "success")
return redirect( return redirect(
@ -272,7 +317,7 @@ def cancel_mailbox_change_route(mailbox_id):
@dashboard_bp.route("/mailbox/confirm_change") @dashboard_bp.route("/mailbox/confirm_change")
def mailbox_confirm_change_route(): def mailbox_confirm_email_change_route():
s = TimestampSigner(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
signed_mailbox_id = request.args.get("mailbox_id") signed_mailbox_id = request.args.get("mailbox_id")
@ -281,30 +326,20 @@ def mailbox_confirm_change_route():
except Exception: except Exception:
flash("Invalid link", "error") flash("Invalid link", "error")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
else:
mailbox = Mailbox.get(mailbox_id)
# new_email can be None if user cancels change in the meantime res = perform_mailbox_email_change(mailbox_id)
if mailbox and mailbox.new_email:
user = mailbox.user
if Mailbox.get_by(email=mailbox.new_email, user_id=user.id):
flash(f"{mailbox.new_email} is already used", "error")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id)
)
mailbox.email = mailbox.new_email flash(res.message, res.message_category)
mailbox.new_email = None if res.error:
if res.error == MailboxEmailChangeError.EmailAlreadyUsed:
# mark mailbox as verified if the change request is sent from an unverified mailbox
mailbox.verified = True
Session.commit()
LOG.d("Mailbox change %s is verified", mailbox)
flash(f"The {mailbox.email} is updated", "success")
return redirect( return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id) url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
) )
else: elif res.error == MailboxEmailChangeError.InvalidId:
flash("Invalid link", "error")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
else:
raise Exception("Unhandled MailboxEmailChangeError")
else:
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)

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

@ -11,6 +11,7 @@ from app.dashboard.base import dashboard_bp
from app.errors import SubdomainInTrashError from app.errors import SubdomainInTrashError
from app.log import LOG from app.log import LOG
from app.models import CustomDomain, Mailbox, SLDomain from app.models import CustomDomain, Mailbox, SLDomain
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
# Only lowercase letters, numbers, dashes (-) are currently supported # Only lowercase letters, numbers, dashes (-) are currently supported
_SUBDOMAIN_PATTERN = r"[0-9a-z-]{1,}" _SUBDOMAIN_PATTERN = r"[0-9a-z-]{1,}"
@ -102,6 +103,12 @@ def subdomain_route():
ownership_verified=True, ownership_verified=True,
commit=True, commit=True,
) )
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.CreateCustomDomain,
message=f"Create subdomain {new_custom_domain.id} ({full_domain})",
commit=True,
)
except SubdomainInTrashError: except SubdomainInTrashError:
flash( flash(
f"{full_domain} has been used before and cannot be reused", f"{full_domain} has been used before and cannot be reused",

View File

@ -32,7 +32,9 @@ def unsubscribe(alias_id):
# automatic unsubscribe, according to https://tools.ietf.org/html/rfc8058 # automatic unsubscribe, according to https://tools.ietf.org/html/rfc8058
if request.method == "POST": if request.method == "POST":
alias_utils.change_alias_status(alias, False) alias_utils.change_alias_status(
alias, enabled=False, message="Set enabled=False from unsubscribe request"
)
flash(f"Alias {alias.email} has been blocked", "success") flash(f"Alias {alias.email} has been blocked", "success")
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

@ -103,7 +103,9 @@ class UnsubscribeHandler:
): ):
return status.E509 return status.E509
LOG.i(f"User disabled alias {alias} via unsubscribe header") LOG.i(f"User disabled alias {alias} via unsubscribe header")
alias_utils.change_alias_status(alias, enabled=False) alias_utils.change_alias_status(
alias, enabled=False, message="Set enabled=False via unsubscribe header"
)
Session.commit() Session.commit()
enable_alias_url = config.URL + f"/dashboard/?highlight_alias_id={alias.id}" enable_alias_url = config.URL + f"/dashboard/?highlight_alias_id={alias.id}"
for mailbox in alias.mailboxes: for mailbox in alias.mailboxes:

View File

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

View File

@ -1,6 +1,6 @@
import dataclasses import dataclasses
import secrets import secrets
import random from enum import Enum
from typing import Optional from typing import Optional
import arrow import arrow
@ -12,10 +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.utils import canonicalize_email, sanitize_email
@dataclasses.dataclass @dataclasses.dataclass
@ -35,8 +38,9 @@ class OnlyPaidError(MailboxError):
class CannotVerifyError(MailboxError): class CannotVerifyError(MailboxError):
def __init__(self, msg: str): def __init__(self, msg: str, deleted_activation_code: bool = False):
self.msg = msg self.msg = msg
self.deleted_activation_code = deleted_activation_code
MAX_ACTIVATION_TRIES = 3 MAX_ACTIVATION_TRIES = 3
@ -50,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"
@ -70,9 +75,15 @@ def create_mailbox(
f"User {user} has tried to create mailbox with {email} but email is invalid" f"User {user} has tried to create mailbox with {email} but email is invalid"
) )
raise MailboxError("Invalid email") raise MailboxError("Invalid email")
new_mailbox = Mailbox.create( new_mailbox: Mailbox = Mailbox.create(
email=email, user_id=user.id, verified=verified, commit=True email=email, user_id=user.id, verified=verified, commit=True
) )
emit_user_audit_log(
user=user,
action=UserAuditLogAction.CreateMailbox,
message=f"Create mailbox {new_mailbox.id} ({new_mailbox.email}). Verified={verified}",
commit=True,
)
if verified: if verified:
LOG.i(f"User {user} as created a pre-verified mailbox with {email}") LOG.i(f"User {user} as created a pre-verified mailbox with {email}")
@ -96,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)
@ -129,7 +143,7 @@ def delete_mailbox(
if not transfer_mailbox.verified: if not transfer_mailbox.verified:
LOG.i(f"User {user} has tried to transfer to a non verified mailbox") LOG.i(f"User {user} has tried to transfer to a non verified mailbox")
MailboxError("Your new mailbox is not verified") raise MailboxError("Your new mailbox is not verified")
# Schedule delete account job # Schedule delete account job
LOG.i( LOG.i(
@ -142,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,
@ -163,17 +178,17 @@ def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox:
f"User {user} failed to verify mailbox {mailbox_id} because it does not exist" f"User {user} failed to verify mailbox {mailbox_id} because it does not exist"
) )
raise MailboxError("Invalid mailbox") raise MailboxError("Invalid mailbox")
if mailbox.user_id != user.id:
LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's owned by another user"
)
raise MailboxError("Invalid mailbox")
if mailbox.verified: if mailbox.verified:
LOG.i( LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's already verified" f"User {user} failed to verify mailbox {mailbox_id} because it's already verified"
) )
clear_activation_codes_for_mailbox(mailbox) clear_activation_codes_for_mailbox(mailbox)
return mailbox return mailbox
if mailbox.user_id != user.id:
LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's owned by another user"
)
raise MailboxError("Invalid mailbox")
activation = ( activation = (
MailboxActivation.filter(MailboxActivation.mailbox_id == mailbox_id) MailboxActivation.filter(MailboxActivation.mailbox_id == mailbox_id)
@ -188,7 +203,10 @@ def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox:
if activation.tries >= MAX_ACTIVATION_TRIES: if activation.tries >= MAX_ACTIVATION_TRIES:
LOG.i(f"User {user} failed to verify mailbox {mailbox_id} more than 3 times") LOG.i(f"User {user} failed to verify mailbox {mailbox_id} more than 3 times")
clear_activation_codes_for_mailbox(mailbox) clear_activation_codes_for_mailbox(mailbox)
raise CannotVerifyError("Invalid activation code. Please request another code.") raise CannotVerifyError(
"Invalid activation code. Please request another code.",
deleted_activation_code=True,
)
if activation.created_at < arrow.now().shift(minutes=-15): if activation.created_at < arrow.now().shift(minutes=-15):
LOG.i( LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because code is too old" f"User {user} failed to verify mailbox {mailbox_id} because code is too old"
@ -204,6 +222,11 @@ def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox:
raise CannotVerifyError("Invalid activation code") raise CannotVerifyError("Invalid activation code")
LOG.i(f"User {user} has verified mailbox {mailbox_id}") LOG.i(f"User {user} has verified mailbox {mailbox_id}")
mailbox.verified = True mailbox.verified = True
emit_user_audit_log(
user=user,
action=UserAuditLogAction.VerifyMailbox,
message=f"Verify mailbox {mailbox_id} ({mailbox.email})",
)
clear_activation_codes_for_mailbox(mailbox) clear_activation_codes_for_mailbox(mailbox)
return mailbox return mailbox
@ -216,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(
@ -261,3 +284,107 @@ def send_verification_email(
mailbox_email=mailbox.email, mailbox_email=mailbox.email,
), ),
) )
class MailboxEmailChangeError(Enum):
InvalidId = 1
EmailAlreadyUsed = 2
@dataclasses.dataclass
class MailboxEmailChangeResult:
error: Optional[MailboxEmailChangeError]
message: str
message_category: str
def perform_mailbox_email_change(mailbox_id: int) -> MailboxEmailChangeResult:
mailbox: Optional[Mailbox] = Mailbox.get(mailbox_id)
# new_email can be None if user cancels change in the meantime
if mailbox and mailbox.new_email:
user = mailbox.user
if Mailbox.get_by(email=mailbox.new_email, user_id=user.id):
return MailboxEmailChangeResult(
error=MailboxEmailChangeError.EmailAlreadyUsed,
message=f"{mailbox.new_email} is already used",
message_category="error",
)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Change mailbox email for mailbox {mailbox_id} (old={mailbox.email} | new={mailbox.new_email})",
)
mailbox.email = mailbox.new_email
mailbox.new_email = None
# mark mailbox as verified if the change request is sent from an unverified mailbox
mailbox.verified = True
Session.commit()
LOG.d("Mailbox change %s is verified", mailbox)
return MailboxEmailChangeResult(
error=None,
message=f"The {mailbox.email} is updated",
message_category="success",
)
else:
return MailboxEmailChangeResult(
error=MailboxEmailChangeError.InvalidId,
message="Invalid link",
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

@ -24,6 +24,7 @@ from sqlalchemy import text, desc, CheckConstraint, Index, Column
from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import deferred from sqlalchemy.orm import deferred
from sqlalchemy.orm.exc import ObjectDeletedError
from sqlalchemy.sql import and_ from sqlalchemy.sql import and_
from sqlalchemy_utils import ArrowType from sqlalchemy_utils import ArrowType
@ -157,6 +158,8 @@ class File(Base, ModelMixin):
path = sa.Column(sa.String(128), unique=True, nullable=False) path = sa.Column(sa.String(128), unique=True, nullable=False)
user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=True) user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=True)
__table_args__ = (sa.Index("ix_file_user_id", "user_id"),)
def get_url(self, expires_in=3600): def get_url(self, expires_in=3600):
return s3.get_url(self.path, expires_in) return s3.get_url(self.path, expires_in)
@ -318,6 +321,8 @@ class HibpNotifiedAlias(Base, ModelMixin):
notified_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False) notified_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False)
__table_args__ = (sa.Index("ix_hibp_notified_alias_user_id", "user_id"),)
class Fido(Base, ModelMixin): class Fido(Base, ModelMixin):
__tablename__ = "fido" __tablename__ = "fido"
@ -332,6 +337,8 @@ class Fido(Base, ModelMixin):
name = sa.Column(sa.String(128), nullable=False, unique=False) name = sa.Column(sa.String(128), nullable=False, unique=False)
user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=True) user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=True)
__table_args__ = (sa.Index("ix_fido_user_id", "user_id"),)
class User(Base, ModelMixin, UserMixin, PasswordOracle): class User(Base, ModelMixin, UserMixin, PasswordOracle):
__tablename__ = "users" __tablename__ = "users"
@ -564,6 +571,11 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
"ix_users_activated_trial_end_lifetime", activated, trial_end, lifetime "ix_users_activated_trial_end_lifetime", activated, trial_end, lifetime
), ),
sa.Index("ix_users_delete_on", delete_on), sa.Index("ix_users_delete_on", delete_on),
sa.Index("ix_users_default_mailbox_id", default_mailbox_id),
sa.Index(
"ix_users_default_alias_custom_domain_id", default_alias_custom_domain_id
),
sa.Index("ix_users_profile_picture_id", profile_picture_id),
) )
@property @property
@ -616,6 +628,15 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
if "alternative_id" not in kwargs: if "alternative_id" not in kwargs:
user.alternative_id = str(uuid.uuid4()) user.alternative_id = str(uuid.uuid4())
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
trail = ". Created from partner" if from_partner else ""
emit_user_audit_log(
user=user,
action=UserAuditLogAction.CreateUser,
message=f"Created user {email}{trail}",
)
# If the user is created from partner, do not notify # If the user is created from partner, do not notify
# nor give a trial # nor give a trial
if from_partner: if from_partner:
@ -1211,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()
@ -1227,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()
@ -1269,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
@ -1327,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()
@ -1375,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"
@ -1396,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()
@ -1418,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()
@ -1571,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])
@ -1656,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
@ -1673,6 +1724,7 @@ class Alias(Base, ModelMixin):
Session.flush() Session.flush()
# Internal import to avoid global import cycles # Internal import to avoid global import cycles
from app.alias_audit_log_utils import AliasAuditLogAction, emit_alias_audit_log
from app.events.event_dispatcher import EventDispatcher from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import AliasCreated, EventContent from app.events.generated.event_pb2 import AliasCreated, EventContent
@ -1684,6 +1736,9 @@ class Alias(Base, ModelMixin):
created_at=int(new_alias.created_at.timestamp), created_at=int(new_alias.created_at.timestamp),
) )
EventDispatcher.send_event(user, EventContent(alias_created=event)) EventDispatcher.send_event(user, EventContent(alias_created=event))
emit_alias_audit_log(
new_alias, AliasAuditLogAction.CreateAlias, "New alias created"
)
return new_alias return new_alias
@ -2055,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
@ -2331,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(
@ -2357,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)
@ -2515,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(
@ -2558,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)
@ -2618,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"
@ -2632,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:
@ -2733,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])
@ -2870,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)
@ -2892,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()
@ -2931,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"
@ -3176,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()
@ -3196,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")
@ -3337,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."""
@ -3438,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"
@ -3612,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"
@ -3682,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)
@ -3704,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
@ -3735,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"
@ -3745,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)
@ -3767,17 +3873,19 @@ class SyncEvent(Base, ModelMixin):
sa.Index("ix_sync_event_taken_time", "taken_time"), sa.Index("ix_sync_event_taken_time", "taken_time"),
) )
def mark_as_taken(self) -> bool: def mark_as_taken(self, allow_taken_older_than: Optional[Arrow] = None) -> bool:
sql = """ try:
UPDATE sync_event taken_condition = ["taken_time IS NULL"]
SET taken_time = :taken_time args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id}
WHERE id = :sync_event_id if allow_taken_older_than:
AND taken_time IS NULL taken_condition.append("taken_time < :taken_older_than")
""" args["taken_older_than"] = allow_taken_older_than.datetime
args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id} sql_taken_condition = "({})".format(" OR ".join(taken_condition))
sql = f"UPDATE sync_event SET taken_time = :taken_time WHERE id = :sync_event_id AND {sql_taken_condition}"
res = Session.execute(sql, args) res = Session.execute(sql, args)
Session.commit() Session.commit()
except ObjectDeletedError:
return False
return res.rowcount > 0 return res.rowcount > 0
@ -3801,3 +3909,39 @@ class SyncEvent(Base, ModelMixin):
.limit(100) .limit(100)
.all() .all()
) )
class AliasAuditLog(Base, ModelMixin):
"""This model holds an audit log for all the actions performed to an alias"""
__tablename__ = "alias_audit_log"
user_id = sa.Column(sa.Integer, nullable=False)
alias_id = sa.Column(sa.Integer, nullable=False)
alias_email = sa.Column(sa.String(255), nullable=False)
action = sa.Column(sa.String(255), nullable=False)
message = sa.Column(sa.Text, default=None, nullable=True)
__table_args__ = (
sa.Index("ix_alias_audit_log_user_id", "user_id"),
sa.Index("ix_alias_audit_log_alias_id", "alias_id"),
sa.Index("ix_alias_audit_log_alias_email", "alias_email"),
sa.Index("ix_alias_audit_log_created_at", "created_at"),
)
class UserAuditLog(Base, ModelMixin):
"""This model holds an audit log for all the actions performed by a user"""
__tablename__ = "user_audit_log"
user_id = sa.Column(sa.Integer, nullable=False)
user_email = sa.Column(sa.String(255), nullable=False)
action = sa.Column(sa.String(255), nullable=False)
message = sa.Column(sa.Text, default=None, nullable=True)
__table_args__ = (
sa.Index("ix_user_audit_log_user_id", "user_id"),
sa.Index("ix_user_audit_log_user_email", "user_email"),
sa.Index("ix_user_audit_log_created_at", "created_at"),
)

View File

@ -0,0 +1,55 @@
from typing import Optional
import arrow
from arrow import Arrow
from app import config
from app.models import PartnerUser, PartnerSubscription, User, Job
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
def create_partner_user(
user: User, partner_id: int, partner_email: str, external_user_id: str
) -> PartnerUser:
instance = PartnerUser.create(
user_id=user.id,
partner_id=partner_id,
partner_email=partner_email,
external_user_id=external_user_id,
)
Job.create(
name=config.JOB_SEND_ALIAS_CREATION_EVENTS,
payload={"user_id": user.id},
run_at=arrow.now(),
)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.LinkAccount,
message=f"Linked account to partner_id={partner_id} | partner_email={partner_email} | external_user_id={external_user_id}",
)
return instance
def create_partner_subscription(
partner_user: PartnerUser,
expiration: Optional[Arrow] = None,
lifetime: bool = False,
msg: Optional[str] = None,
) -> PartnerSubscription:
instance = PartnerSubscription.create(
partner_user_id=partner_user.id,
end_at=expiration,
lifetime=lifetime,
)
message = "User upgraded through partner subscription"
if msg:
message += f" | {msg}"
emit_user_audit_log(
user=partner_user.user,
action=UserAuditLogAction.Upgrade,
message=message,
)
return instance

View File

View File

@ -0,0 +1,121 @@
from typing import Optional
import arrow
from coinbase_commerce.error import WebhookInvalidPayload, SignatureVerificationError
from coinbase_commerce.webhook import Webhook
from flask import Flask, request
from app.config import COINBASE_WEBHOOK_SECRET
from app.db import Session
from app.email_utils import send_email, render
from app.log import LOG
from app.models import CoinbaseSubscription, User
from app.subscription_webhook import execute_subscription_webhook
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
def setup_coinbase_commerce(app: Flask):
@app.route("/coinbase", methods=["POST"])
def coinbase_webhook():
# event payload
request_data = request.data.decode("utf-8")
# webhook signature
request_sig = request.headers.get("X-CC-Webhook-Signature", None)
try:
# signature verification and event object construction
event = Webhook.construct_event(
request_data, request_sig, COINBASE_WEBHOOK_SECRET
)
except (WebhookInvalidPayload, SignatureVerificationError) as e:
LOG.e("Invalid Coinbase webhook")
return str(e), 400
LOG.d("Coinbase event %s", event)
if event["type"] == "charge:confirmed":
if handle_coinbase_event(event):
return "success", 200
else:
return "error", 400
return "success", 200
def handle_coinbase_event(event) -> bool:
server_user_id = event["data"]["metadata"]["user_id"]
try:
user_id = int(server_user_id)
except ValueError:
user_id = int(float(server_user_id))
code = event["data"]["code"]
user: Optional[User] = User.get(user_id)
if not user:
LOG.e("User not found %s", user_id)
return False
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
user_id=user_id
)
if not coinbase_subscription:
LOG.d("Create a coinbase subscription for %s", user)
coinbase_subscription = CoinbaseSubscription.create(
user_id=user_id, end_at=arrow.now().shift(years=1), code=code, commit=True
)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.Upgrade,
message="Upgraded though Coinbase",
commit=True,
)
send_email(
user.email,
"Your SimpleLogin account has been upgraded",
render(
"transactional/coinbase/new-subscription.txt",
user=user,
coinbase_subscription=coinbase_subscription,
),
render(
"transactional/coinbase/new-subscription.html",
user=user,
coinbase_subscription=coinbase_subscription,
),
)
else:
if coinbase_subscription.code != code:
LOG.d("Update code from %s to %s", coinbase_subscription.code, code)
coinbase_subscription.code = code
if coinbase_subscription.is_active():
coinbase_subscription.end_at = coinbase_subscription.end_at.shift(years=1)
else: # already expired subscription
coinbase_subscription.end_at = arrow.now().shift(years=1)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.SubscriptionExtended,
message="Extended coinbase subscription",
)
Session.commit()
send_email(
user.email,
"Your SimpleLogin account has been extended",
render(
"transactional/coinbase/extend-subscription.txt",
user=user,
coinbase_subscription=coinbase_subscription,
),
render(
"transactional/coinbase/extend-subscription.html",
user=user,
coinbase_subscription=coinbase_subscription,
),
)
execute_subscription_webhook(user)
return True

286
app/app/payments/paddle.py Normal file
View File

@ -0,0 +1,286 @@
import arrow
import json
from dateutil.relativedelta import relativedelta
from flask import Flask, request
from app import paddle_utils, paddle_callback
from app.config import (
PADDLE_MONTHLY_PRODUCT_ID,
PADDLE_MONTHLY_PRODUCT_IDS,
PADDLE_YEARLY_PRODUCT_IDS,
PADDLE_COUPON_ID,
)
from app.db import Session
from app.email_utils import send_email, render
from app.log import LOG
from app.models import Subscription, PlanEnum, User, Coupon
from app.subscription_webhook import execute_subscription_webhook
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import random_string
def setup_paddle_callback(app: Flask):
@app.route("/paddle", methods=["GET", "POST"])
def paddle():
LOG.d(f"paddle callback {request.form.get('alert_name')} {request.form}")
# make sure the request comes from Paddle
if not paddle_utils.verify_incoming_request(dict(request.form)):
LOG.e("request not coming from paddle. Request data:%s", dict(request.form))
return "KO", 400
if (
request.form.get("alert_name") == "subscription_created"
): # new user subscribes
# the passthrough is json encoded, e.g.
# request.form.get("passthrough") = '{"user_id": 88 }'
passthrough = json.loads(request.form.get("passthrough"))
user_id = passthrough.get("user_id")
user = User.get(user_id)
subscription_plan_id = int(request.form.get("subscription_plan_id"))
if subscription_plan_id in PADDLE_MONTHLY_PRODUCT_IDS:
plan = PlanEnum.monthly
elif subscription_plan_id in PADDLE_YEARLY_PRODUCT_IDS:
plan = PlanEnum.yearly
else:
LOG.e(
"Unknown subscription_plan_id %s %s",
subscription_plan_id,
request.form,
)
return "No such subscription", 400
sub = Subscription.get_by(user_id=user.id)
if not sub:
LOG.d(f"create a new Subscription for user {user}")
Subscription.create(
user_id=user.id,
cancel_url=request.form.get("cancel_url"),
update_url=request.form.get("update_url"),
subscription_id=request.form.get("subscription_id"),
event_time=arrow.now(),
next_bill_date=arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date(),
plan=plan,
)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.Upgrade,
message="Upgraded through Paddle",
)
else:
LOG.d(f"Update an existing Subscription for user {user}")
sub.cancel_url = request.form.get("cancel_url")
sub.update_url = request.form.get("update_url")
sub.subscription_id = request.form.get("subscription_id")
sub.event_time = arrow.now()
sub.next_bill_date = arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date()
sub.plan = plan
# make sure to set the new plan as not-cancelled
# in case user cancels a plan and subscribes a new plan
sub.cancelled = False
emit_user_audit_log(
user=user,
action=UserAuditLogAction.SubscriptionExtended,
message="Extended Paddle subscription",
)
execute_subscription_webhook(user)
LOG.d("User %s upgrades!", user)
Session.commit()
elif request.form.get("alert_name") == "subscription_payment_succeeded":
subscription_id = request.form.get("subscription_id")
LOG.d("Update subscription %s", subscription_id)
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
# when user subscribes, the "subscription_payment_succeeded" can arrive BEFORE "subscription_created"
# at that time, subscription object does not exist yet
if sub:
sub.event_time = arrow.now()
sub.next_bill_date = arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date()
Session.commit()
execute_subscription_webhook(sub.user)
elif request.form.get("alert_name") == "subscription_cancelled":
subscription_id = request.form.get("subscription_id")
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
if sub:
# cancellation_effective_date should be the same as next_bill_date
LOG.w(
"Cancel subscription %s %s on %s, next bill date %s",
subscription_id,
sub.user,
request.form.get("cancellation_effective_date"),
sub.next_bill_date,
)
sub.event_time = arrow.now()
sub.cancelled = True
emit_user_audit_log(
user=sub.user,
action=UserAuditLogAction.SubscriptionCancelled,
message="Cancelled Paddle subscription",
)
Session.commit()
user = sub.user
send_email(
user.email,
"SimpleLogin - your subscription is canceled",
render(
"transactional/subscription-cancel.txt",
user=user,
end_date=request.form.get("cancellation_effective_date"),
),
)
execute_subscription_webhook(sub.user)
else:
# user might have deleted their account
LOG.i(f"Cancel non-exist subscription {subscription_id}")
return "OK"
elif request.form.get("alert_name") == "subscription_updated":
subscription_id = request.form.get("subscription_id")
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
if sub:
next_bill_date = request.form.get("next_bill_date")
if not next_bill_date:
paddle_callback.failed_payment(sub, subscription_id)
return "OK"
LOG.d(
"Update subscription %s %s on %s, next bill date %s",
subscription_id,
sub.user,
request.form.get("cancellation_effective_date"),
sub.next_bill_date,
)
if (
int(request.form.get("subscription_plan_id"))
== PADDLE_MONTHLY_PRODUCT_ID
):
plan = PlanEnum.monthly
else:
plan = PlanEnum.yearly
sub.cancel_url = request.form.get("cancel_url")
sub.update_url = request.form.get("update_url")
sub.event_time = arrow.now()
sub.next_bill_date = arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date()
sub.plan = plan
# make sure to set the new plan as not-cancelled
sub.cancelled = False
emit_user_audit_log(
user=sub.user,
action=UserAuditLogAction.SubscriptionExtended,
message="Extended Paddle subscription",
)
Session.commit()
execute_subscription_webhook(sub.user)
else:
LOG.w(
f"update non-exist subscription {subscription_id}. {request.form}"
)
return "No such subscription", 400
elif request.form.get("alert_name") == "payment_refunded":
subscription_id = request.form.get("subscription_id")
LOG.d("Refund request for subscription %s", subscription_id)
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
if sub:
user = sub.user
Subscription.delete(sub.id)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.SubscriptionCancelled,
message="Paddle subscription cancelled as user requested a refund",
)
Session.commit()
LOG.e("%s requests a refund", user)
execute_subscription_webhook(sub.user)
elif request.form.get("alert_name") == "subscription_payment_refunded":
subscription_id = request.form.get("subscription_id")
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
LOG.d(
"Handle subscription_payment_refunded for subscription %s",
subscription_id,
)
if not sub:
LOG.w(
"No such subscription for %s, payload %s",
subscription_id,
request.form,
)
return "No such subscription"
plan_id = int(request.form["subscription_plan_id"])
if request.form["refund_type"] == "full":
if plan_id in PADDLE_MONTHLY_PRODUCT_IDS:
LOG.d("subtract 1 month from next_bill_date %s", sub.next_bill_date)
sub.next_bill_date = sub.next_bill_date - relativedelta(months=1)
LOG.d("next_bill_date is %s", sub.next_bill_date)
Session.commit()
elif plan_id in PADDLE_YEARLY_PRODUCT_IDS:
LOG.d("subtract 1 year from next_bill_date %s", sub.next_bill_date)
sub.next_bill_date = sub.next_bill_date - relativedelta(years=1)
LOG.d("next_bill_date is %s", sub.next_bill_date)
Session.commit()
else:
LOG.e("Unknown plan_id %s", plan_id)
else:
LOG.w("partial subscription_payment_refunded, not handled")
execute_subscription_webhook(sub.user)
return "OK"
@app.route("/paddle_coupon", methods=["GET", "POST"])
def paddle_coupon():
LOG.d("paddle coupon callback %s", request.form)
if not paddle_utils.verify_incoming_request(dict(request.form)):
LOG.e("request not coming from paddle. Request data:%s", dict(request.form))
return "KO", 400
product_id = request.form.get("p_product_id")
if product_id != PADDLE_COUPON_ID:
LOG.e("product_id %s not match with %s", product_id, PADDLE_COUPON_ID)
return "KO", 400
email = request.form.get("email")
LOG.d("Paddle coupon request for %s", email)
coupon = Coupon.create(
code=random_string(30),
comment="For 1-year coupon",
expires_date=arrow.now().shift(years=1, days=-1),
commit=True,
)
return (
f"Your 1-year coupon is <b>{coupon.code}</b> <br> "
f"It's valid until <b>{coupon.expires_date.date().isoformat()}</b>"
)

View File

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

View File

@ -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("PlanExpiration", "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

@ -5,6 +5,7 @@ from app.db import Session
from app.log import LOG from app.log import LOG
from app.errors import ProtonPartnerNotSetUp from app.errors import ProtonPartnerNotSetUp
from app.models import Partner, PartnerUser, User from app.models import Partner, PartnerUser, User
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
PROTON_PARTNER_NAME = "Proton" PROTON_PARTNER_NAME = "Proton"
_PROTON_PARTNER: Optional[Partner] = None _PROTON_PARTNER: Optional[Partner] = None
@ -32,6 +33,11 @@ def perform_proton_account_unlink(current_user: User):
) )
if partner_user is not None: if partner_user is not None:
LOG.info(f"User {current_user} has unlinked the account from {partner_user}") LOG.info(f"User {current_user} has unlinked the account from {partner_user}")
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UnlinkAccount,
message=f"User has unlinked the account (email={partner_user.partner_email} | external_user_id={partner_user.external_user_id})",
)
PartnerUser.delete(partner_user.id) PartnerUser.delete(partner_user.id)
Session.commit() Session.commit()
agent.record_custom_event("AccountUnlinked", {"partner": proton_partner.name}) agent.record_custom_event("AccountUnlinked", {"partner": proton_partner.name})

View File

@ -1,40 +1,16 @@
import requests
from requests import RequestException
from app import config
from app.db import Session from app.db import Session
from app.events.event_dispatcher import EventDispatcher from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import EventContent, UserPlanChanged from app.events.generated.event_pb2 import EventContent, UserPlanChanged
from app.log import LOG
from app.models import User from app.models import User
def execute_subscription_webhook(user: User): def execute_subscription_webhook(user: User):
webhook_url = config.SUBSCRIPTION_CHANGE_WEBHOOK
if webhook_url is None:
return
subscription_end = user.get_active_subscription_end( subscription_end = user.get_active_subscription_end(
include_partner_subscription=False include_partner_subscription=False
) )
sl_subscription_end = None sl_subscription_end = None
if subscription_end: if subscription_end:
sl_subscription_end = subscription_end.timestamp sl_subscription_end = subscription_end.timestamp
payload = {
"user_id": user.id,
"is_premium": user.is_premium(),
"active_subscription_end": sl_subscription_end,
}
try:
response = requests.post(webhook_url, json=payload, timeout=2)
if response.status_code == 200:
LOG.i("Sent request to subscription update webhook successfully")
else:
LOG.i(
f"Request to webhook failed with status {response.status_code}: {response.text}"
)
except RequestException as e:
LOG.error(f"Subscription request exception: {e}")
event = UserPlanChanged(plan_end_time=sl_subscription_end) event = UserPlanChanged(plan_end_time=sl_subscription_end)
EventDispatcher.send_event(user, EventContent(user_plan_change=event)) EventDispatcher.send_event(user, EventContent(user_plan_change=event))
Session.commit() Session.commit()

View File

@ -0,0 +1,44 @@
from enum import Enum
from app.models import User, UserAuditLog
class UserAuditLogAction(Enum):
CreateUser = "create_user"
ActivateUser = "activate_user"
ResetPassword = "reset_password"
Upgrade = "upgrade"
SubscriptionExtended = "subscription_extended"
SubscriptionCancelled = "subscription_cancelled"
LinkAccount = "link_account"
UnlinkAccount = "unlink_account"
CreateMailbox = "create_mailbox"
VerifyMailbox = "verify_mailbox"
UpdateMailbox = "update_mailbox"
DeleteMailbox = "delete_mailbox"
CreateCustomDomain = "create_custom_domain"
VerifyCustomDomain = "verify_custom_domain"
UpdateCustomDomain = "update_custom_domain"
DeleteCustomDomain = "delete_custom_domain"
CreateDirectory = "create_directory"
UpdateDirectory = "update_directory"
DeleteDirectory = "delete_directory"
UserMarkedForDeletion = "user_marked_for_deletion"
DeleteUser = "delete_user"
def emit_user_audit_log(
user: User, action: UserAuditLogAction, message: str, commit: bool = False
):
UserAuditLog.create(
user_id=user.id,
user_email=user.email,
action=action.value,
message=message,
commit=commit,
)

View File

@ -3,6 +3,7 @@ from typing import Optional
from app.db import Session from app.db import Session
from app.log import LOG from app.log import LOG
from app.models import User, SLDomain, CustomDomain, Mailbox from app.models import User, SLDomain, CustomDomain, Mailbox
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
class CannotSetAlias(Exception): class CannotSetAlias(Exception):
@ -54,7 +55,7 @@ def set_default_alias_domain(user: User, domain_name: Optional[str]):
def set_default_mailbox(user: User, mailbox_id: int) -> Mailbox: def set_default_mailbox(user: User, mailbox_id: int) -> Mailbox:
mailbox = Mailbox.get(mailbox_id) mailbox: Optional[Mailbox] = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != user.id: if not mailbox or mailbox.user_id != user.id:
raise CannotSetMailbox("Invalid mailbox") raise CannotSetMailbox("Invalid mailbox")
@ -67,5 +68,11 @@ def set_default_mailbox(user: User, mailbox_id: int) -> Mailbox:
LOG.i(f"User {user} has set mailbox {mailbox} as his default one") LOG.i(f"User {user} has set mailbox {mailbox} as his default one")
user.default_mailbox_id = mailbox.id user.default_mailbox_id = mailbox.id
emit_user_audit_log(
user=user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Set mailbox {mailbox.id} ({mailbox.email}) as default",
)
Session.commit() Session.commit()
return mailbox return mailbox

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

@ -60,8 +60,11 @@ from app.models import (
) )
from app.pgp_utils import load_public_key_and_check, PGPException from app.pgp_utils import load_public_key_and_check, PGPException
from app.proton.utils import get_proton_partner from app.proton.utils import get_proton_partner
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import sanitize_email from app.utils import sanitize_email
from server import create_light_app from server import create_light_app
from tasks.clean_alias_audit_log import cleanup_alias_audit_log
from tasks.clean_user_audit_log import cleanup_user_audit_log
from tasks.cleanup_old_imports import cleanup_old_imports from tasks.cleanup_old_imports import cleanup_old_imports
from tasks.cleanup_old_jobs import cleanup_old_jobs from tasks.cleanup_old_jobs import cleanup_old_jobs
from tasks.cleanup_old_notifications import cleanup_old_notifications from tasks.cleanup_old_notifications import cleanup_old_notifications
@ -283,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
@ -897,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
@ -968,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.
@ -987,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)
@ -1218,7 +1252,7 @@ def notify_hibp():
def clear_users_scheduled_to_be_deleted(dry_run=False): def clear_users_scheduled_to_be_deleted(dry_run=False):
users = User.filter( users: List[User] = User.filter(
and_( and_(
User.delete_on.isnot(None), User.delete_on.isnot(None),
User.delete_on <= arrow.now().shift(days=-DELETE_GRACE_DAYS), User.delete_on <= arrow.now().shift(days=-DELETE_GRACE_DAYS),
@ -1230,6 +1264,11 @@ def clear_users_scheduled_to_be_deleted(dry_run=False):
) )
if dry_run: if dry_run:
continue continue
emit_user_audit_log(
user=user,
action=UserAuditLogAction.DeleteUser,
message=f"Delete user {user.id} ({user.email})",
)
User.delete(user.id) User.delete(user.id)
Session.commit() Session.commit()
@ -1241,6 +1280,16 @@ def delete_old_data():
cleanup_old_notifications(oldest_valid) cleanup_old_notifications(oldest_valid)
def clear_alias_audit_log():
oldest_valid = arrow.now().shift(days=-config.AUDIT_LOG_MAX_DAYS)
cleanup_alias_audit_log(oldest_valid)
def clear_user_audit_log():
oldest_valid = arrow.now().shift(days=-config.AUDIT_LOG_MAX_DAYS)
cleanup_user_audit_log(oldest_valid)
if __name__ == "__main__": if __name__ == "__main__":
LOG.d("Start running cronjob") LOG.d("Start running cronjob")
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@ -1249,22 +1298,6 @@ if __name__ == "__main__":
"--job", "--job",
help="Choose a cron job to run", help="Choose a cron job to run",
type=str, type=str,
choices=[
"stats",
"notify_trial_end",
"notify_manual_subscription_end",
"notify_premium_end",
"delete_logs",
"delete_old_data",
"poll_apple_subscription",
"sanity_check",
"delete_old_monitoring",
"check_custom_domain",
"check_hibp",
"notify_hibp",
"cleanup_tokens",
"send_undelivered_mails",
],
) )
args = parser.parse_args() args = parser.parse_args()
# wrap in an app context to benefit from app setup like database cleanup, sentry integration, etc # wrap in an app context to benefit from app setup like database cleanup, sentry integration, etc
@ -1313,4 +1346,10 @@ if __name__ == "__main__":
load_unsent_mails_from_fs_and_resend() load_unsent_mails_from_fs_and_resend()
elif args.job == "delete_scheduled_users": elif args.job == "delete_scheduled_users":
LOG.d("Deleting users scheduled to be deleted") LOG.d("Deleting users scheduled to be deleted")
clear_users_scheduled_to_be_deleted(dry_run=True) clear_users_scheduled_to_be_deleted()
elif args.job == "clear_alias_audit_log":
LOG.d("Clearing alias audit log")
clear_alias_audit_log()
elif args.job == "clear_user_audit_log":
LOG.d("Clearing user audit log")
clear_user_audit_log()

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
@ -80,3 +94,17 @@ jobs:
schedule: "*/5 * * * *" schedule: "*/5 * * * *"
captureStderr: true captureStderr: true
concurrencyPolicy: Forbid concurrencyPolicy: Forbid
- name: SimpleLogin clear alias_audit_log old entries
command: python /code/cron.py -j clear_alias_audit_log
shell: /bin/bash
schedule: "0 * * * *" # Once every hour
captureStderr: true
concurrencyPolicy: Forbid
- name: SimpleLogin clear user_audit_log old entries
command: python /code/cron.py -j clear_user_audit_log
shell: /bin/bash
schedule: "0 * * * *" # Once every hour
captureStderr: true
concurrencyPolicy: Forbid

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
): ):
@ -1502,7 +1484,9 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
LOG.w( LOG.w(
f"Disable alias {alias} because {reason}. {alias.mailboxes} {alias.user}. Last contact {contact}" f"Disable alias {alias} because {reason}. {alias.mailboxes} {alias.user}. Last contact {contact}"
) )
change_alias_status(alias, enabled=False) change_alias_status(
alias, enabled=False, message=f"Set enabled=False due to {reason}"
)
Notification.create( Notification.create(
user_id=user.id, user_id=user.id,

View File

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

View File

@ -85,24 +85,28 @@ class DeadLetterEventSource(EventSource):
def __init__(self, max_retries: int): def __init__(self, max_retries: int):
self.__max_retries = max_retries self.__max_retries = max_retries
def execute_loop(
self, on_event: Callable[[SyncEvent], NoReturn]
) -> list[SyncEvent]:
threshold = arrow.utcnow().shift(minutes=-_DEAD_LETTER_THRESHOLD_MINUTES)
events = SyncEvent.get_dead_letter(
older_than=threshold, max_retries=self.__max_retries
)
if events:
LOG.info(f"Got {len(events)} dead letter events")
newrelic.agent.record_custom_metric(
"Custom/dead_letter_events_to_process", len(events)
)
for event in events:
if event.mark_as_taken(allow_taken_older_than=threshold):
on_event(event)
return events
@newrelic.agent.background_task() @newrelic.agent.background_task()
def run(self, on_event: Callable[[SyncEvent], NoReturn]): def run(self, on_event: Callable[[SyncEvent], NoReturn]):
while True: while True:
try: try:
threshold = arrow.utcnow().shift( events = self.execute_loop(on_event)
minutes=-_DEAD_LETTER_THRESHOLD_MINUTES
)
events = SyncEvent.get_dead_letter(
older_than=threshold, max_retries=self.__max_retries
)
if events:
LOG.info(f"Got {len(events)} dead letter events")
if events:
newrelic.agent.record_custom_metric(
"Custom/dead_letter_events_to_process", len(events)
)
for event in events:
on_event(event)
Session.close() # Ensure that we have a new connection and we don't have a dangling tx with a lock Session.close() # Ensure that we have a new connection and we don't have a dangling tx with a lock
if not events: if not events:
LOG.debug("No dead letter events") LOG.debug("No dead letter events")

View File

@ -18,8 +18,10 @@ from app.events.event_dispatcher import PostgresDispatcher
from app.import_utils import handle_batch_import from app.import_utils import handle_batch_import
from app.jobs.event_jobs import send_alias_creation_events_for_user from app.jobs.event_jobs import send_alias_creation_events_for_user
from app.jobs.export_user_data_job import ExportUserDataJob from app.jobs.export_user_data_job import ExportUserDataJob
from app.jobs.send_event_job import SendEventToWebhookJob
from app.log import LOG from app.log import LOG
from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from server import create_light_app from server import create_light_app
@ -128,7 +130,7 @@ def welcome_proton(user):
def delete_mailbox_job(job: Job): def delete_mailbox_job(job: Job):
mailbox_id = job.payload.get("mailbox_id") mailbox_id = job.payload.get("mailbox_id")
mailbox = Mailbox.get(mailbox_id) mailbox: Optional[Mailbox] = Mailbox.get(mailbox_id)
if not mailbox: if not mailbox:
return return
@ -152,10 +154,18 @@ def delete_mailbox_job(job: Job):
mailbox_email = mailbox.email mailbox_email = mailbox.email
user = mailbox.user user = mailbox.user
emit_user_audit_log(
user=user,
action=UserAuditLogAction.DeleteMailbox,
message=f"Delete mailbox {mailbox.id} ({mailbox.email})",
)
Mailbox.delete(mailbox_id) Mailbox.delete(mailbox_id)
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,
@ -244,6 +254,7 @@ def process_job(job: Job):
if not custom_domain: if not custom_domain:
return return
is_subdomain = custom_domain.is_sl_subdomain
domain_name = custom_domain.domain domain_name = custom_domain.domain
user = custom_domain.user user = custom_domain.user
@ -251,6 +262,16 @@ def process_job(job: Job):
CustomDomain.delete(custom_domain.id) CustomDomain.delete(custom_domain.id)
Session.commit() Session.commit()
if is_subdomain:
message = f"Delete subdomain {custom_domain_id} ({domain_name})"
else:
message = f"Delete custom domain {custom_domain_id} ({domain_name})"
emit_user_audit_log(
user=user,
action=UserAuditLogAction.DeleteCustomDomain,
message=message,
)
LOG.d("Domain %s deleted", domain_name) LOG.d("Domain %s deleted", domain_name)
if custom_domain_partner_id is None: if custom_domain_partner_id is None:
@ -282,6 +303,10 @@ def process_job(job: Job):
send_alias_creation_events_for_user( send_alias_creation_events_for_user(
user, dispatcher=PostgresDispatcher.get() user, dispatcher=PostgresDispatcher.get()
) )
elif job.name == config.JOB_SEND_EVENT_TO_WEBHOOK:
send_job = SendEventToWebhookJob.create_from_job(job)
if send_job:
send_job.run()
else: else:
LOG.e("Unknown job name %s", job.name) LOG.e("Unknown job name %s", job.name)

View File

@ -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,45 @@
"""alias_audit_log
Revision ID: 91ed7f46dc81
Revises: 62afa3a10010
Create Date: 2024-10-11 13:22:11.594054
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '91ed7f46dc81'
down_revision = '62afa3a10010'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('alias_audit_log',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('alias_id', sa.Integer(), nullable=False),
sa.Column('alias_email', sa.String(length=255), nullable=False),
sa.Column('action', sa.String(length=255), nullable=False),
sa.Column('message', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_alias_audit_log_alias_email', 'alias_audit_log', ['alias_email'], unique=False)
op.create_index('ix_alias_audit_log_alias_id', 'alias_audit_log', ['alias_id'], unique=False)
op.create_index('ix_alias_audit_log_user_id', 'alias_audit_log', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_alias_audit_log_user_id', table_name='alias_audit_log')
op.drop_index('ix_alias_audit_log_alias_id', table_name='alias_audit_log')
op.drop_index('ix_alias_audit_log_alias_email', table_name='alias_audit_log')
op.drop_table('alias_audit_log')
# ### end Alembic commands ###

View File

@ -0,0 +1,44 @@
"""user_audit_log
Revision ID: 7d7b84779837
Revises: 91ed7f46dc81
Create Date: 2024-10-16 11:52:49.128644
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7d7b84779837'
down_revision = '91ed7f46dc81'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user_audit_log',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('user_email', sa.String(length=255), nullable=False),
sa.Column('action', sa.String(length=255), nullable=False),
sa.Column('message', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_user_audit_log_user_email', 'user_audit_log', ['user_email'], unique=False)
op.create_index('ix_user_audit_log_user_id', 'user_audit_log', ['user_id'], unique=False)
op.create_index('ix_user_audit_log_created_at', 'user_audit_log', ['created_at'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_user_audit_log_user_id', table_name='user_audit_log')
op.drop_index('ix_user_audit_log_user_email', table_name='user_audit_log')
op.drop_index('ix_user_audit_log_created_at', table_name='user_audit_log')
op.drop_table('user_audit_log')
# ### end Alembic commands ###

View File

@ -0,0 +1,27 @@
"""alias_audit_log_index_created_at
Revision ID: 32f25cbf12f6
Revises: 7d7b84779837
Create Date: 2024-10-16 16:45:36.827161
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '32f25cbf12f6'
down_revision = '7d7b84779837'
branch_labels = None
depends_on = None
def upgrade():
with op.get_context().autocommit_block():
op.create_index('ix_alias_audit_log_created_at', 'alias_audit_log', ['created_at'], unique=False, postgresql_concurrently=True)
def downgrade():
with op.get_context().autocommit_block():
op.drop_index('ix_alias_audit_log_created_at', table_name='alias_audit_log', postgresql_concurrently=True)

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

@ -21,7 +21,7 @@ if max_alias_id == 0:
max_alias_id = Session.query(func.max(Alias.id)).scalar() max_alias_id = Session.query(func.max(Alias.id)).scalar()
print(f"Checking alias {alias_id_start} to {max_alias_id}") print(f"Checking alias {alias_id_start} to {max_alias_id}")
step = 1000 step = 10000
noteSql = "(note = 'Created through Proton' or note = 'Created through partner Proton')" noteSql = "(note = 'Created through Proton' or note = 'Created through partner Proton')"
alias_query = f"UPDATE alias set note = NULL, flags = flags | :flag where id>=:start AND id<:end and {noteSql}" alias_query = f"UPDATE alias set note = NULL, flags = flags | :flag where id>=:start AND id<:end and {noteSql}"
updated = 0 updated = 0
@ -38,12 +38,12 @@ for batch_start in range(alias_id_start, max_alias_id, step):
updated += rows_done.rowcount updated += rows_done.rowcount
Session.commit() Session.commit()
elapsed = time.time() - start_time elapsed = time.time() - start_time
time_per_alias = elapsed / (updated + 1)
last_batch_id = batch_start + step last_batch_id = batch_start + step
time_per_alias = elapsed / (last_batch_id)
remaining = max_alias_id - last_batch_id remaining = max_alias_id - last_batch_id
time_remaining = (max_alias_id - last_batch_id) * time_per_alias time_remaining = remaining / time_per_alias
hours_remaining = time_remaining / 3600.0 hours_remaining = time_remaining / 60.0
print( print(
f"\rAlias {batch_start}/{max_alias_id} {updated} {hours_remaining:.2f}hrs remaining" f"\rAlias {batch_start}/{max_alias_id} {updated} {hours_remaining:.2f} mins remaining"
) )
print("") print("")

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

@ -0,0 +1,58 @@
#!/usr/bin/env python3
import argparse
import time
import arrow
from sqlalchemy import func
from app.account_linking import send_user_plan_changed_event
from app.models import PartnerUser
from app.db import Session
parser = argparse.ArgumentParser(
prog="Backfill alias", description="Update alias notes and backfill flag"
)
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 = 100
updated = 0
start_time = time.time()
with_premium = 0
with_lifetime = 0
for batch_start in range(pu_id_start, max_pu_id, step):
partner_users = (
Session.query(PartnerUser).filter(
PartnerUser.id >= batch_start, PartnerUser.id < batch_start + step
)
).all()
for partner_user in partner_users:
subscription_end = send_user_plan_changed_event(partner_user)
if subscription_end is not None:
if subscription_end > arrow.get("2038-01-01").timestamp:
with_lifetime += 1
else:
with_premium += 1
updated += 1
Session.commit()
elapsed = time.time() - start_time
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} {updated} {hours_remaining:.2f} mins remaining"
)
print(f"With SL premium {with_premium} lifetime {with_lifetime}")

View File

@ -1,4 +1,3 @@
import json
import os import os
import time import time
from datetime import timedelta from datetime import timedelta
@ -9,9 +8,7 @@ import flask_limiter
import flask_profiler import flask_profiler
import newrelic.agent import newrelic.agent
import sentry_sdk import sentry_sdk
from coinbase_commerce.error import WebhookInvalidPayload, SignatureVerificationError
from coinbase_commerce.webhook import Webhook
from dateutil.relativedelta import relativedelta
from flask import ( from flask import (
Flask, Flask,
redirect, redirect,
@ -30,7 +27,7 @@ from sentry_sdk.integrations.flask import FlaskIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from app import paddle_utils, config, paddle_callback, constants from app import config, constants
from app.admin_model import ( from app.admin_model import (
SLAdminIndexView, SLAdminIndexView,
UserAdmin, UserAdmin,
@ -56,7 +53,6 @@ from app.config import (
FLASK_SECRET, FLASK_SECRET,
SENTRY_DSN, SENTRY_DSN,
URL, URL,
PADDLE_MONTHLY_PRODUCT_ID,
FLASK_PROFILER_PATH, FLASK_PROFILER_PATH,
FLASK_PROFILER_PASSWORD, FLASK_PROFILER_PASSWORD,
SENTRY_FRONT_END_DSN, SENTRY_FRONT_END_DSN,
@ -70,22 +66,16 @@ from app.config import (
LANDING_PAGE_URL, LANDING_PAGE_URL,
STATUS_PAGE_URL, STATUS_PAGE_URL,
SUPPORT_EMAIL, SUPPORT_EMAIL,
PADDLE_MONTHLY_PRODUCT_IDS,
PADDLE_YEARLY_PRODUCT_IDS,
PGP_SIGNER, PGP_SIGNER,
COINBASE_WEBHOOK_SECRET,
PAGE_LIMIT, PAGE_LIMIT,
PADDLE_COUPON_ID,
ZENDESK_ENABLED, ZENDESK_ENABLED,
MAX_NB_EMAIL_FREE_PLAN, MAX_NB_EMAIL_FREE_PLAN,
MEM_STORE_URI, MEM_STORE_URI,
) )
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.subscription_webhook import execute_subscription_webhook
from app.db import Session from app.db import Session
from app.developer.base import developer_bp from app.developer.base import developer_bp
from app.discover.base import discover_bp from app.discover.base import discover_bp
from app.email_utils import send_email, render
from app.extensions import login_manager, limiter from app.extensions import login_manager, limiter
from app.fake_data import fake_data from app.fake_data import fake_data
from app.internal.base import internal_bp from app.internal.base import internal_bp
@ -94,11 +84,8 @@ from app.log import LOG
from app.models import ( from app.models import (
User, User,
Alias, Alias,
Subscription,
PlanEnum,
CustomDomain, CustomDomain,
Mailbox, Mailbox,
CoinbaseSubscription,
EmailLog, EmailLog,
Contact, Contact,
ManualSubscription, ManualSubscription,
@ -115,10 +102,11 @@ from app.monitor.base import monitor_bp
from app.newsletter_utils import send_newsletter_to_user from app.newsletter_utils import send_newsletter_to_user
from app.oauth.base import oauth_bp from app.oauth.base import oauth_bp
from app.onboarding.base import onboarding_bp from app.onboarding.base import onboarding_bp
from app.payments.coinbase import setup_coinbase_commerce
from app.payments.paddle import setup_paddle_callback
from app.phone.base import phone_bp from app.phone.base import phone_bp
from app.redis_services import initialize_redis_services from app.redis_services import initialize_redis_services
from app.sentry_utils import sentry_before_send from app.sentry_utils import sentry_before_send
from app.utils import random_string
if SENTRY_DSN: if SENTRY_DSN:
LOG.d("enable sentry") LOG.d("enable sentry")
@ -446,341 +434,6 @@ def jinja2_filter(app):
) )
def setup_paddle_callback(app: Flask):
@app.route("/paddle", methods=["GET", "POST"])
def paddle():
LOG.d(f"paddle callback {request.form.get('alert_name')} {request.form}")
# make sure the request comes from Paddle
if not paddle_utils.verify_incoming_request(dict(request.form)):
LOG.e("request not coming from paddle. Request data:%s", dict(request.form))
return "KO", 400
if (
request.form.get("alert_name") == "subscription_created"
): # new user subscribes
# the passthrough is json encoded, e.g.
# request.form.get("passthrough") = '{"user_id": 88 }'
passthrough = json.loads(request.form.get("passthrough"))
user_id = passthrough.get("user_id")
user = User.get(user_id)
subscription_plan_id = int(request.form.get("subscription_plan_id"))
if subscription_plan_id in PADDLE_MONTHLY_PRODUCT_IDS:
plan = PlanEnum.monthly
elif subscription_plan_id in PADDLE_YEARLY_PRODUCT_IDS:
plan = PlanEnum.yearly
else:
LOG.e(
"Unknown subscription_plan_id %s %s",
subscription_plan_id,
request.form,
)
return "No such subscription", 400
sub = Subscription.get_by(user_id=user.id)
if not sub:
LOG.d(f"create a new Subscription for user {user}")
Subscription.create(
user_id=user.id,
cancel_url=request.form.get("cancel_url"),
update_url=request.form.get("update_url"),
subscription_id=request.form.get("subscription_id"),
event_time=arrow.now(),
next_bill_date=arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date(),
plan=plan,
)
else:
LOG.d(f"Update an existing Subscription for user {user}")
sub.cancel_url = request.form.get("cancel_url")
sub.update_url = request.form.get("update_url")
sub.subscription_id = request.form.get("subscription_id")
sub.event_time = arrow.now()
sub.next_bill_date = arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date()
sub.plan = plan
# make sure to set the new plan as not-cancelled
# in case user cancels a plan and subscribes a new plan
sub.cancelled = False
execute_subscription_webhook(user)
LOG.d("User %s upgrades!", user)
Session.commit()
elif request.form.get("alert_name") == "subscription_payment_succeeded":
subscription_id = request.form.get("subscription_id")
LOG.d("Update subscription %s", subscription_id)
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
# when user subscribes, the "subscription_payment_succeeded" can arrive BEFORE "subscription_created"
# at that time, subscription object does not exist yet
if sub:
sub.event_time = arrow.now()
sub.next_bill_date = arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date()
Session.commit()
execute_subscription_webhook(sub.user)
elif request.form.get("alert_name") == "subscription_cancelled":
subscription_id = request.form.get("subscription_id")
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
if sub:
# cancellation_effective_date should be the same as next_bill_date
LOG.w(
"Cancel subscription %s %s on %s, next bill date %s",
subscription_id,
sub.user,
request.form.get("cancellation_effective_date"),
sub.next_bill_date,
)
sub.event_time = arrow.now()
sub.cancelled = True
Session.commit()
user = sub.user
send_email(
user.email,
"SimpleLogin - your subscription is canceled",
render(
"transactional/subscription-cancel.txt",
user=user,
end_date=request.form.get("cancellation_effective_date"),
),
)
execute_subscription_webhook(sub.user)
else:
# user might have deleted their account
LOG.i(f"Cancel non-exist subscription {subscription_id}")
return "OK"
elif request.form.get("alert_name") == "subscription_updated":
subscription_id = request.form.get("subscription_id")
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
if sub:
next_bill_date = request.form.get("next_bill_date")
if not next_bill_date:
paddle_callback.failed_payment(sub, subscription_id)
return "OK"
LOG.d(
"Update subscription %s %s on %s, next bill date %s",
subscription_id,
sub.user,
request.form.get("cancellation_effective_date"),
sub.next_bill_date,
)
if (
int(request.form.get("subscription_plan_id"))
== PADDLE_MONTHLY_PRODUCT_ID
):
plan = PlanEnum.monthly
else:
plan = PlanEnum.yearly
sub.cancel_url = request.form.get("cancel_url")
sub.update_url = request.form.get("update_url")
sub.event_time = arrow.now()
sub.next_bill_date = arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date()
sub.plan = plan
# make sure to set the new plan as not-cancelled
sub.cancelled = False
Session.commit()
execute_subscription_webhook(sub.user)
else:
LOG.w(
f"update non-exist subscription {subscription_id}. {request.form}"
)
return "No such subscription", 400
elif request.form.get("alert_name") == "payment_refunded":
subscription_id = request.form.get("subscription_id")
LOG.d("Refund request for subscription %s", subscription_id)
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
if sub:
user = sub.user
Subscription.delete(sub.id)
Session.commit()
LOG.e("%s requests a refund", user)
execute_subscription_webhook(sub.user)
elif request.form.get("alert_name") == "subscription_payment_refunded":
subscription_id = request.form.get("subscription_id")
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
LOG.d(
"Handle subscription_payment_refunded for subscription %s",
subscription_id,
)
if not sub:
LOG.w(
"No such subscription for %s, payload %s",
subscription_id,
request.form,
)
return "No such subscription"
plan_id = int(request.form["subscription_plan_id"])
if request.form["refund_type"] == "full":
if plan_id in PADDLE_MONTHLY_PRODUCT_IDS:
LOG.d("subtract 1 month from next_bill_date %s", sub.next_bill_date)
sub.next_bill_date = sub.next_bill_date - relativedelta(months=1)
LOG.d("next_bill_date is %s", sub.next_bill_date)
Session.commit()
elif plan_id in PADDLE_YEARLY_PRODUCT_IDS:
LOG.d("subtract 1 year from next_bill_date %s", sub.next_bill_date)
sub.next_bill_date = sub.next_bill_date - relativedelta(years=1)
LOG.d("next_bill_date is %s", sub.next_bill_date)
Session.commit()
else:
LOG.e("Unknown plan_id %s", plan_id)
else:
LOG.w("partial subscription_payment_refunded, not handled")
execute_subscription_webhook(sub.user)
return "OK"
@app.route("/paddle_coupon", methods=["GET", "POST"])
def paddle_coupon():
LOG.d("paddle coupon callback %s", request.form)
if not paddle_utils.verify_incoming_request(dict(request.form)):
LOG.e("request not coming from paddle. Request data:%s", dict(request.form))
return "KO", 400
product_id = request.form.get("p_product_id")
if product_id != PADDLE_COUPON_ID:
LOG.e("product_id %s not match with %s", product_id, PADDLE_COUPON_ID)
return "KO", 400
email = request.form.get("email")
LOG.d("Paddle coupon request for %s", email)
coupon = Coupon.create(
code=random_string(30),
comment="For 1-year coupon",
expires_date=arrow.now().shift(years=1, days=-1),
commit=True,
)
return (
f"Your 1-year coupon is <b>{coupon.code}</b> <br> "
f"It's valid until <b>{coupon.expires_date.date().isoformat()}</b>"
)
def setup_coinbase_commerce(app):
@app.route("/coinbase", methods=["POST"])
def coinbase_webhook():
# event payload
request_data = request.data.decode("utf-8")
# webhook signature
request_sig = request.headers.get("X-CC-Webhook-Signature", None)
try:
# signature verification and event object construction
event = Webhook.construct_event(
request_data, request_sig, COINBASE_WEBHOOK_SECRET
)
except (WebhookInvalidPayload, SignatureVerificationError) as e:
LOG.e("Invalid Coinbase webhook")
return str(e), 400
LOG.d("Coinbase event %s", event)
if event["type"] == "charge:confirmed":
if handle_coinbase_event(event):
return "success", 200
else:
return "error", 400
return "success", 200
def handle_coinbase_event(event) -> bool:
server_user_id = event["data"]["metadata"]["user_id"]
try:
user_id = int(server_user_id)
except ValueError:
user_id = int(float(server_user_id))
code = event["data"]["code"]
user = User.get(user_id)
if not user:
LOG.e("User not found %s", user_id)
return False
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
user_id=user_id
)
if not coinbase_subscription:
LOG.d("Create a coinbase subscription for %s", user)
coinbase_subscription = CoinbaseSubscription.create(
user_id=user_id, end_at=arrow.now().shift(years=1), code=code, commit=True
)
send_email(
user.email,
"Your SimpleLogin account has been upgraded",
render(
"transactional/coinbase/new-subscription.txt",
user=user,
coinbase_subscription=coinbase_subscription,
),
render(
"transactional/coinbase/new-subscription.html",
user=user,
coinbase_subscription=coinbase_subscription,
),
)
else:
if coinbase_subscription.code != code:
LOG.d("Update code from %s to %s", coinbase_subscription.code, code)
coinbase_subscription.code = code
if coinbase_subscription.is_active():
coinbase_subscription.end_at = coinbase_subscription.end_at.shift(years=1)
else: # already expired subscription
coinbase_subscription.end_at = arrow.now().shift(years=1)
Session.commit()
send_email(
user.email,
"Your SimpleLogin account has been extended",
render(
"transactional/coinbase/extend-subscription.txt",
user=user,
coinbase_subscription=coinbase_subscription,
),
render(
"transactional/coinbase/extend-subscription.html",
user=user,
coinbase_subscription=coinbase_subscription,
),
)
execute_subscription_webhook(user)
return True
def init_extensions(app: Flask): def init_extensions(app: Flask):
login_manager.init_app(app) login_manager.init_app(app)
@ -789,10 +442,10 @@ def init_admin(app):
admin = Admin(name="SimpleLogin", template_mode="bootstrap4") admin = Admin(name="SimpleLogin", template_mode="bootstrap4")
admin.init_app(app, index_view=SLAdminIndexView()) admin.init_app(app, index_view=SLAdminIndexView())
admin.add_view(EmailSearchAdmin(name="Email Search", endpoint="email_search"))
admin.add_view(UserAdmin(User, Session)) admin.add_view(UserAdmin(User, Session))
admin.add_view(AliasAdmin(Alias, Session)) admin.add_view(AliasAdmin(Alias, Session))
admin.add_view(MailboxAdmin(Mailbox, Session)) admin.add_view(MailboxAdmin(Mailbox, Session))
admin.add_view(EmailSearchAdmin(name="Email Search", endpoint="email_search"))
admin.add_view(CouponAdmin(Coupon, Session)) admin.add_view(CouponAdmin(Coupon, Session))
admin.add_view(ManualSubscriptionAdmin(ManualSubscription, Session)) admin.add_view(ManualSubscriptionAdmin(ManualSubscription, Session))
admin.add_view(CustomDomainAdmin(CustomDomain, Session)) admin.add_view(CustomDomainAdmin(CustomDomain, Session))

View File

@ -0,0 +1,12 @@
import arrow
from app.db import Session
from app.log import LOG
from app.models import AliasAuditLog
def cleanup_alias_audit_log(oldest_allowed: arrow.Arrow):
LOG.i(f"Deleting alias_audit_log older than {oldest_allowed}")
count = AliasAuditLog.filter(AliasAuditLog.created_at < oldest_allowed).delete()
Session.commit()
LOG.i(f"Deleted {count} alias_audit_log entries")

View File

@ -0,0 +1,12 @@
import arrow
from app.db import Session
from app.log import LOG
from app.models import UserAuditLog
def cleanup_user_audit_log(oldest_allowed: arrow.Arrow):
LOG.i(f"Deleting user_audit_log older than {oldest_allowed}")
count = UserAuditLog.filter(UserAuditLog.created_at < oldest_allowed).delete()
Session.commit()
LOG.i(f"Deleted {count} user_audit_log entries")

View File

@ -1,220 +1,295 @@
{% extends 'admin/master.html' %} {% extends 'admin/master.html' %}
{% macro show_user(user) -%} {% macro show_user(user) -%}
<h4>User {{ user.email }} with ID {{ user.id }}.</h4> <h4>User {{ user.email }} with ID {{ user.id }}.</h4>
{% set pu = helper.partner_user(user) %} {% set pu = helper.partner_user(user) %}
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th scope="col">User ID</th> <th scope="col">User ID</th>
<th scope="col">Email</th> <th scope="col">Email</th>
<th scope="col">Status</th> <th scope="col">Verified</th>
<th scope="col">Paid</th> <th scope="col">Status</th>
<th>Subscription</th> <th scope="col">Paid</th>
<th>Created At</th> <th scope="col">Premium</th>
<th>Updated At</th> <th>Subscription</th>
<th>Connected with Proton account</th> <th>Created At</th>
</tr> <th>Updated At</th>
</thead> <th>Connected with Proton account</th>
<tbody> </tr>
<tr> </thead>
<td>{{ user.id }}</td> <tbody>
<td><a href="?email={{ user.email }}">{{ user.email }}</a></td> <tr>
{% if user.disabled %} <td>{{ user.id }}</td>
<td>
<a href="?email={{ user.email }}">{{ user.email }}</a>
</td>
{% if user.activated %}
<td class="text-danger">Disabled</td> <td class="text-success">Activated</td>
{% else %} {% else %}
<td class="text-success">Enabled</td> <td class="text-warning">Pending</td>
{% endif %} {% endif %}
<td>{{ "yes" if user.is_paid() else "No" }}</td> {% if user.disabled %}
<td>{{ user.get_active_subscription() }}</td>
<td>{{ user.created_at }}</td>
<td>{{ user.updated_at }}</td>
{% if pu %}
<td><a href="?email={{ pu.partner_email }}">{{ pu.partner_email }}</a></td> <td class="text-danger">Disabled</td>
{% else %} {% else %}
<td>No</td> <td class="text-success">Enabled</td>
{% endif %} {% endif %}
</tr> <td>{{ "yes" if user.is_paid() else "No" }}</td>
</tbody> <td>{{ "yes" if user.is_premium() else "No" }}</td>
</table> <td>{{ user.get_active_subscription() }}</td>
<td>{{ user.created_at }}</td>
<td>{{ user.updated_at }}</td>
{% if pu %}
<td>
<a href="?email={{ pu.partner_email }}">{{ pu.partner_email }}</a>
</td>
{% else %}
<td>No</td>
{% endif %}
</tr>
</tbody>
</table>
{%- endmacro %} {%- endmacro %}
{% macro list_mailboxes(message, mbox_count, mboxes) %} {% macro list_mailboxes(message, mbox_count, mboxes) %}
<h4> <h4>
{{ mbox_count }} {{ message }}. {{ mbox_count }} {{ message }}.
{% if mbox_count>10 %}Showing only the last 10.{% endif %} {% if mbox_count>10 %}Showing only the last 10.{% endif %}
</h4> </h4>
<table class="table"> <table class="table">
<thead> <thead>
<tr>
<th>Mailbox ID</th>
<th>Email</th>
<th>Verified</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{% for mailbox in mboxes %}
<tr> <tr>
<th>Mailbox ID</th> <td>{{ mailbox.id }}</td>
<th>Email</th> <td>
<th>Verified</th> <a href="?email={{ mailbox.email }}">{{ mailbox.email }}</a>
<th>Created At</th> </td>
<td>{{ "Yes" if mailbox.verified else "No" }}</td>
<td>{{ mailbox.created_at }}</td>
</tr> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for mailbox in mboxes %} </table>
<tr>
<td>{{ mailbox.id }}</td>
<td><a href="?email={{ mailbox.email }}">{{ mailbox.email }}</a></td>
<td>{{ "Yes" if mailbox.verified else "No" }}</td>
<td>
{{ mailbox.created_at }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %} {% endmacro %}
{% macro list_alias(alias_count, aliases) %} {% macro list_alias(alias_count, aliases) %}
<h4> <h4>
{{ alias_count }} Aliases found. {{ alias_count }} Aliases found.
{% if alias_count>10 %}Showing only the last 10.{% endif %} {% if alias_count>10 %}Showing only the last 10.{% endif %}
</h4> </h4>
<table class="table"> <table class="table">
<thead> <thead>
<tr>
<th>Alias ID</th>
<th>Email</th>
<th>Enabled</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{% for alias in aliases %}
<tr> <tr>
<th> <td>{{ alias.id }}</td>
Alias ID <td>
</th> <a href="?email={{ alias.email }}">{{ alias.email }}</a>
<th> </td>
Email <td>{{ "Yes" if alias.enabled else "No" }}</td>
</th> <td>{{ alias.created_at }}</td>
<th>
Enabled
</th>
<th>
Created At
</th>
</tr> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for alias in aliases %} </table>
<tr>
<td>{{ alias.id }}</td>
<td><a href="?email={{ alias.email }}">{{ alias.email }}</a></td>
<td>{{ "Yes" if alias.enabled else "No" }}</td>
<td>{{ alias.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %} {% endmacro %}
{% macro show_deleted_alias(deleted_alias) -%} {% macro show_deleted_alias(deleted_alias) -%}
<h4>Deleted Alias {{ deleted_alias.email }} with ID {{ deleted_alias.id }}.</h4> <h4>Deleted Alias {{ deleted_alias.email }} with ID {{ deleted_alias.id }}.</h4>
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th scope="col">Deleted Alias ID</th> <th scope="col">Deleted Alias ID</th>
<th scope="col">Email</th> <th scope="col">Email</th>
<th scope="col">Deleted At</th> <th scope="col">Deleted At</th>
<th scope="col">Reason</th> <th scope="col">Reason</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>{{ deleted_alias.id }}</td> <td>{{ deleted_alias.id }}</td>
<td>{{ deleted_alias.email }}</td> <td>{{ deleted_alias.email }}</td>
<td>{{ deleted_alias.created_at }}</td> <td>{{ deleted_alias.created_at }}</td>
<td>{{ deleted_alias.reason }}</td> <td>{{ deleted_alias.reason }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
{%- endmacro %} {%- endmacro %}
{% macro show_domain_deleted_alias(dom_deleted_alias) -%} {% macro show_domain_deleted_alias(dom_deleted_alias) -%}
<h4> <h4>
Domain Deleted Alias {{ dom_deleted_alias.email }} with ID {{ dom_deleted_alias.id }} for Domain Deleted Alias {{ dom_deleted_alias.email }} with ID {{ dom_deleted_alias.id }} for
domain {{ dom_deleted_alias.domain.domain }} domain {{ dom_deleted_alias.domain.domain }}
</h4> </h4>
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th scope="col">Deleted Alias ID</th> <th scope="col">Deleted Alias ID</th>
<th scope="col">Email</th> <th scope="col">Email</th>
<th scope="col">Domain</th> <th scope="col">Domain</th>
<th scope="col">Domain ID</th> <th scope="col">Domain ID</th>
<th scope="col">Domain owner user ID</th> <th scope="col">Domain owner user ID</th>
<th scope="col">Domain owner user email</th> <th scope="col">Domain owner user email</th>
<th scope="col">Deleted At</th> <th scope="col">Deleted At</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>{{ dom_deleted_alias.id }}</td> <td>{{ dom_deleted_alias.id }}</td>
<td>{{ dom_deleted_alias.email }}</td> <td>{{ dom_deleted_alias.email }}</td>
<td>{{ dom_deleted_alias.domain.domain }}</td> <td>{{ dom_deleted_alias.domain.domain }}</td>
<td>{{ dom_deleted_alias.domain.id }}</td> <td>{{ dom_deleted_alias.domain.id }}</td>
<td>{{ dom_deleted_alias.domain.user_id }}</td> <td>{{ dom_deleted_alias.domain.user_id }}</td>
<td>{{ dom_deleted_alias.created_at }}</td> <td>{{ dom_deleted_alias.created_at }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
{{ show_user(data.domain_deleted_alias.domain.user) }} {{ show_user(data.domain_deleted_alias.domain.user) }}
{%- endmacro %} {%- endmacro %}
{% macro list_alias_audit_log(alias_audit_log) %}
<h4>Alias Audit Log</h4>
<table class="table">
<thead>
<tr>
<th>User ID</th>
<th>Alias ID</th>
<th>Alias Email</th>
<th>Action</th>
<th>Message</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for entry in alias_audit_log %}
<tr>
<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 %}
{% macro list_user_audit_log(user_audit_log) %}
<h4>User Audit Log</h4>
<table class="table">
<thead>
<tr>
<th>User email</th>
<th>Action</th>
<th>Message</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for entry in user_audit_log %}
<tr>
<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 %}
{% 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_mailboxes("Mailboxes for alias", helper.alias_mailbox_count(data.alias), helper.alias_mailboxes(data.alias)) }} {{ list_alias(helper.alias_count(data.user) ,helper.alias_list(data.user)) }}
{{ show_user(data.alias.user) }} </div>
</div> {% endif %}
{% endif %} {% if data.user_audit_log %}
{% 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.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]) }}
</div> {{ show_user(mailbox.user) }}
{% endif %} </div>
{% if data.domain_deleted_alias %} {% 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 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) }}
</div> {{ list_alias_audit_log(data.deleted_alias_audit_log) }}
{% endif %} </div>
{% endif %}
{% if data.domain_deleted_alias %}
<div class="border border-dark mt-1 mb-2 p-3">
<h3 class="mb-3">Found DomainDeletedAlias {{ data.domain_deleted_alias.email }}</h3>
{{ show_domain_deleted_alias(data.domain_deleted_alias) }}
{{ list_alias_audit_log(data.domain_deleted_alias_audit_log) }}
</div>
{% endif %}
{% endblock %} {% 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

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

View File

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

View File

@ -0,0 +1,54 @@
import arrow
from app.db import Session
from app.models import SyncEvent
from events.event_source import DeadLetterEventSource, _DEAD_LETTER_THRESHOLD_MINUTES
class EventCounter:
def __init__(self):
self.processed_events = 0
def on_event(self, event: SyncEvent):
self.processed_events += 1
def setup_function(func):
Session.query(SyncEvent).delete()
def test_dead_letter_does_not_take_untaken_events():
source = DeadLetterEventSource(1)
counter = EventCounter()
threshold_time = arrow.utcnow().shift(minutes=-(_DEAD_LETTER_THRESHOLD_MINUTES) + 1)
SyncEvent.create(
content="test".encode("utf-8"), created_at=threshold_time, flush=True
)
SyncEvent.create(
content="test".encode("utf-8"), taken_time=threshold_time, flush=True
)
events_processed = source.execute_loop(on_event=counter.on_event)
assert len(events_processed) == 0
assert counter.processed_events == 0
def test_dead_letter_takes_untaken_events_created_older_than_threshold():
source = DeadLetterEventSource(1)
counter = EventCounter()
old_create = arrow.utcnow().shift(minutes=-_DEAD_LETTER_THRESHOLD_MINUTES - 1)
SyncEvent.create(content="test".encode("utf-8"), created_at=old_create, flush=True)
events_processed = source.execute_loop(on_event=counter.on_event)
assert len(events_processed) == 1
assert events_processed[0].taken_time > old_create
assert counter.processed_events == 1
def test_dead_letter_takes_taken_events_created_older_than_threshold():
source = DeadLetterEventSource(1)
counter = EventCounter()
old_taken = arrow.utcnow().shift(minutes=-_DEAD_LETTER_THRESHOLD_MINUTES - 1)
SyncEvent.create(content="test".encode("utf-8"), taken_time=old_taken, flush=True)
events_processed = source.execute_loop(on_event=counter.on_event)
assert len(events_processed) == 1
assert events_processed[0].taken_time > old_taken
assert counter.processed_events == 1

View File

@ -1,7 +1,9 @@
import arrow
from app import config, alias_utils from app import config, alias_utils
from app.db import Session from app.db import Session
from app.events.event_dispatcher import GlobalDispatcher from app.events.event_dispatcher import GlobalDispatcher
from app.models import Alias from app.models import Alias, SyncEvent
from tests.utils import random_token from tests.utils import random_token
from .event_test_utils import ( from .event_test_utils import (
OnMemoryDispatcher, OnMemoryDispatcher,
@ -26,6 +28,33 @@ def setup_function(func):
on_memory_dispatcher.clear() on_memory_dispatcher.clear()
def test_event_taken_updates():
event = SyncEvent.create(content="test".encode("utf-8"), flush=True)
assert event.taken_time is None
assert event.mark_as_taken()
assert event.taken_time is not None
def test_event_mark_as_taken_does_nothing_for_taken_events():
now = arrow.utcnow()
event = SyncEvent.create(content="test".encode("utf-8"), taken_time=now, flush=True)
assert not event.mark_as_taken()
def test_event_mark_as_taken_does_nothing_for_not_before_events():
now = arrow.utcnow()
event = SyncEvent.create(content="test".encode("utf-8"), taken_time=now, flush=True)
older_than = now.shift(minutes=-1)
assert not event.mark_as_taken(allow_taken_older_than=older_than)
def test_event_mark_as_taken_works_for_before_events():
now = arrow.utcnow()
event = SyncEvent.create(content="test".encode("utf-8"), taken_time=now, flush=True)
older_than = now.shift(minutes=+1)
assert event.mark_as_taken(allow_taken_older_than=older_than)
def test_fire_event_on_alias_creation(): def test_fire_event_on_alias_creation():
(user, pu) = _create_linked_user() (user, pu) = _create_linked_user()
alias = Alias.create_new_random(user) alias = Alias.create_new_random(user)
@ -79,7 +108,7 @@ def test_fire_event_on_alias_status_change():
alias = Alias.create_new_random(user) alias = Alias.create_new_random(user)
Session.flush() Session.flush()
on_memory_dispatcher.clear() on_memory_dispatcher.clear()
alias_utils.change_alias_status(alias, True) alias_utils.change_alias_status(alias, enabled=True)
assert len(on_memory_dispatcher.memory) == 1 assert len(on_memory_dispatcher.memory) == 1
event_data = on_memory_dispatcher.memory[0] event_data = on_memory_dispatcher.memory[0]
event_content = _get_event_from_string(event_data, user, pu) event_content = _get_event_from_string(event_data, user, pu)

View File

@ -0,0 +1,109 @@
import arrow
from app import config
from app.events.event_dispatcher import GlobalDispatcher
from app.events.generated.event_pb2 import UserPlanChanged
from app.models import (
Subscription,
AppleSubscription,
CoinbaseSubscription,
ManualSubscription,
User,
PartnerUser,
)
from .event_test_utils import (
OnMemoryDispatcher,
_create_linked_user,
_get_event_from_string,
)
from tests.utils import random_token
from app.subscription_webhook import execute_subscription_webhook
on_memory_dispatcher = OnMemoryDispatcher()
def setup_module():
GlobalDispatcher.set_dispatcher(on_memory_dispatcher)
config.EVENT_WEBHOOK = "http://test"
def teardown_module():
GlobalDispatcher.set_dispatcher(None)
config.EVENT_WEBHOOK = None
def setup_function(func):
on_memory_dispatcher.clear()
def check_event(user: User, pu: PartnerUser) -> UserPlanChanged:
assert len(on_memory_dispatcher.memory) == 1
event_data = on_memory_dispatcher.memory[0]
event_content = _get_event_from_string(event_data, user, pu)
assert event_content.user_plan_change is not None
plan_change = event_content.user_plan_change
return plan_change
def test_webhook_with_trial():
(user, pu) = _create_linked_user()
execute_subscription_webhook(user)
assert check_event(user, pu).plan_end_time == 0
def test_webhook_with_subscription():
(user, pu) = _create_linked_user()
end_at = arrow.utcnow().shift(days=1).replace(hour=0, minute=0, second=0)
Subscription.create(
user_id=user.id,
cancel_url="",
update_url="",
subscription_id=random_token(10),
event_time=arrow.now(),
next_bill_date=end_at.date(),
plan="yearly",
flush=True,
)
execute_subscription_webhook(user)
assert check_event(user, pu).plan_end_time == end_at.timestamp
def test_webhook_with_apple_subscription():
(user, pu) = _create_linked_user()
end_at = arrow.utcnow().shift(days=2).replace(hour=0, minute=0, second=0)
AppleSubscription.create(
user_id=user.id,
receipt_data=arrow.now().date().strftime("%Y-%m-%d"),
expires_date=end_at.date().strftime("%Y-%m-%d"),
original_transaction_id=random_token(10),
plan="yearly",
product_id="",
flush=True,
)
execute_subscription_webhook(user)
assert check_event(user, pu).plan_end_time == end_at.timestamp
def test_webhook_with_coinbase_subscription():
(user, pu) = _create_linked_user()
end_at = arrow.utcnow().shift(days=3).replace(hour=0, minute=0, second=0)
CoinbaseSubscription.create(
user_id=user.id, end_at=end_at.date().strftime("%Y-%m-%d"), flush=True
)
execute_subscription_webhook(user)
assert check_event(user, pu).plan_end_time == end_at.timestamp
def test_webhook_with_manual_subscription():
(user, pu) = _create_linked_user()
end_at = arrow.utcnow().shift(days=3).replace(hour=0, minute=0, second=0)
ManualSubscription.create(
user_id=user.id, end_at=end_at.date().strftime("%Y-%m-%d"), flush=True
)
execute_subscription_webhook(user)
assert check_event(user, pu).plan_end_time == end_at.timestamp

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

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

View File

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

View File

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

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

View File

@ -1,3 +1,5 @@
from typing import List
import pytest import pytest
from arrow import Arrow from arrow import Arrow
@ -16,8 +18,9 @@ from app.account_linking import (
) )
from app.db import Session from app.db import Session
from app.errors import AccountAlreadyLinkedToAnotherPartnerException from app.errors import AccountAlreadyLinkedToAnotherPartnerException
from app.models import Partner, PartnerUser, User from app.models import Partner, PartnerUser, User, UserAuditLog
from app.proton.utils import get_proton_partner from app.proton.utils import get_proton_partner
from app.user_audit_log_utils import UserAuditLogAction
from app.utils import random_string, canonicalize_email from app.utils import random_string, canonicalize_email
from tests.utils import random_email from tests.utils import random_email
@ -91,6 +94,13 @@ def test_login_case_from_partner():
) )
assert res.user.activated is True assert res.user.activated is True
audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=res.user.id,
action=UserAuditLogAction.LinkAccount.value,
).all()
assert len(audit_logs) == 1
assert audit_logs[0].user_id == res.user.id
def test_login_case_from_partner_with_uppercase_email(): def test_login_case_from_partner_with_uppercase_email():
partner = get_proton_partner() partner = get_proton_partner()
@ -125,6 +135,29 @@ def test_login_case_from_web():
assert 0 == (res.user.flags & User.FLAG_CREATED_FROM_PARTNER) assert 0 == (res.user.flags & User.FLAG_CREATED_FROM_PARTNER)
assert res.user.activated is True assert res.user.activated is True
audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=res.user.id,
action=UserAuditLogAction.LinkAccount.value,
).all()
assert len(audit_logs) == 1
assert audit_logs[0].user_id == res.user.id
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()
@ -205,6 +238,13 @@ def test_link_account_with_proton_account_same_address(flask_client):
) )
assert partner_user.partner_id == get_proton_partner().id assert partner_user.partner_id == get_proton_partner().id
assert partner_user.external_user_id == partner_user_id assert partner_user.external_user_id == partner_user_id
audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=res.user.id,
action=UserAuditLogAction.LinkAccount.value,
).all()
assert len(audit_logs) == 1
assert audit_logs[0].user_id == res.user.id
assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value
def test_link_account_with_proton_account_different_address(flask_client): def test_link_account_with_proton_account_different_address(flask_client):
@ -229,6 +269,14 @@ def test_link_account_with_proton_account_different_address(flask_client):
assert partner_user.partner_id == get_proton_partner().id assert partner_user.partner_id == get_proton_partner().id
assert partner_user.external_user_id == partner_user_id assert partner_user.external_user_id == partner_user_id
audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=res.user.id,
action=UserAuditLogAction.LinkAccount.value,
).all()
assert len(audit_logs) == 1
assert audit_logs[0].user_id == res.user.id
assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value
def test_link_account_with_proton_account_same_address_but_linked_to_other_user( def test_link_account_with_proton_account_same_address_but_linked_to_other_user(
flask_client, flask_client,
@ -248,22 +296,54 @@ def test_link_account_with_proton_account_same_address_but_linked_to_other_user(
partner_user_id, email=random_email() partner_user_id, email=random_email()
) # User already linked with the proton account ) # User already linked with the proton account
# START Ensure sl_user_2 has a partner_user with the right data
partner_user = PartnerUser.get_by(
partner_id=get_proton_partner().id, user_id=sl_user_2.id
)
assert partner_user is not None
assert partner_user.partner_id == get_proton_partner().id
assert partner_user.external_user_id == partner_user_id
assert partner_user.partner_email == sl_user_2.email
assert partner_user.user_id == sl_user_2.id
# END Ensure sl_user_2 has a partner_user with the right data
# Proceed to link sl_user_1
res = process_link_case(link_request, sl_user_1, get_proton_partner()) res = process_link_case(link_request, sl_user_1, get_proton_partner())
# Check that the result is linking sl_user_1
assert res.user.id == sl_user_1.id assert res.user.id == sl_user_1.id
assert res.user.email == partner_email assert res.user.email == partner_email
assert res.strategy == "Link" assert res.strategy == "Link"
# Ensure partner_user for sl_user_1 exists
partner_user = PartnerUser.get_by( partner_user = PartnerUser.get_by(
partner_id=get_proton_partner().id, user_id=sl_user_1.id partner_id=get_proton_partner().id, user_id=sl_user_1.id
) )
assert partner_user.partner_id == get_proton_partner().id assert partner_user.partner_id == get_proton_partner().id
assert partner_user.external_user_id == partner_user_id assert partner_user.external_user_id == partner_user_id
# Ensure partner_user for sl_user_2 does not exist anymore
partner_user = PartnerUser.get_by( partner_user = PartnerUser.get_by(
partner_id=get_proton_partner().id, user_id=sl_user_2.id partner_id=get_proton_partner().id, user_id=sl_user_2.id
) )
assert partner_user is None assert partner_user is None
# Ensure audit logs for sl_user_1 show the link action
sl_user_1_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=sl_user_1.id,
action=UserAuditLogAction.LinkAccount.value,
).all()
assert len(sl_user_1_audit_logs) == 1
assert sl_user_1_audit_logs[0].user_id == sl_user_1.id
# Ensure audit logs for sl_user_2 show the unlink action
sl_user_2_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=sl_user_2.id,
action=UserAuditLogAction.UnlinkAccount.value,
).all()
assert len(sl_user_2_audit_logs) == 1
assert sl_user_2_audit_logs[0].user_id == sl_user_2.id
def test_link_account_with_proton_account_different_address_and_linked_to_other_user( def test_link_account_with_proton_account_different_address_and_linked_to_other_user(
flask_client, flask_client,
@ -300,6 +380,22 @@ def test_link_account_with_proton_account_different_address_and_linked_to_other_
) )
assert partner_user_2 is None assert partner_user_2 is None
# Ensure audit logs for sl_user_1 show the link action
sl_user_1_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=sl_user_1.id,
action=UserAuditLogAction.LinkAccount.value,
).all()
assert len(sl_user_1_audit_logs) == 1
assert sl_user_1_audit_logs[0].user_id == sl_user_1.id
# Ensure audit logs for sl_user_2 show the unlink action
sl_user_2_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(
user_id=sl_user_2.id,
action=UserAuditLogAction.UnlinkAccount.value,
).all()
assert len(sl_user_2_audit_logs) == 1
assert sl_user_2_audit_logs[0].user_id == sl_user_2.id
def test_cannot_create_instance_of_base_strategy(): def test_cannot_create_instance_of_base_strategy():
with pytest.raises(Exception): with pytest.raises(Exception):

View File

@ -0,0 +1,95 @@
import random
from app.alias_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction
from app.alias_utils import delete_alias, transfer_alias
from app.models import Alias, AliasAuditLog, AliasDeleteReason
from app.utils import random_string
from tests.utils import create_new_user, random_email
def test_emit_alias_audit_log_for_random_data():
user = create_new_user()
alias = Alias.create(
user_id=user.id,
email=random_email(),
mailbox_id=user.default_mailbox_id,
)
random_user_id = random.randint(1000, 2000)
message = random_string()
action = AliasAuditLogAction.ChangeAliasStatus
emit_alias_audit_log(
alias=alias,
user_id=random_user_id,
action=action,
message=message,
commit=True,
)
logs_for_alias = AliasAuditLog.filter_by(alias_id=alias.id).all()
assert len(logs_for_alias) == 2
last_log = logs_for_alias[-1]
assert last_log.alias_id == alias.id
assert last_log.alias_email == alias.email
assert last_log.user_id == random_user_id
assert last_log.action == action.value
assert last_log.message == message
def test_emit_alias_audit_log_on_alias_creation():
user = create_new_user()
alias = Alias.create(
user_id=user.id,
email=random_email(),
mailbox_id=user.default_mailbox_id,
)
log_for_alias = AliasAuditLog.filter_by(alias_id=alias.id).all()
assert len(log_for_alias) == 1
assert log_for_alias[0].alias_id == alias.id
assert log_for_alias[0].alias_email == alias.email
assert log_for_alias[0].user_id == user.id
assert log_for_alias[0].action == AliasAuditLogAction.CreateAlias.value
def test_alias_audit_log_exists_after_alias_deletion():
user = create_new_user()
alias = Alias.create(
user_id=user.id,
email=random_email(),
mailbox_id=user.default_mailbox_id,
)
alias_id = alias.id
emit_alias_audit_log(alias, AliasAuditLogAction.UpdateAlias, "")
emit_alias_audit_log(alias, AliasAuditLogAction.UpdateAlias, "")
delete_alias(alias, user, AliasDeleteReason.ManualAction, commit=True)
db_alias = Alias.get_by(id=alias_id)
assert db_alias is None
logs_for_alias = AliasAuditLog.filter_by(alias_id=alias.id).all()
assert len(logs_for_alias) == 4
assert logs_for_alias[0].action == AliasAuditLogAction.CreateAlias.value
assert logs_for_alias[1].action == AliasAuditLogAction.UpdateAlias.value
assert logs_for_alias[2].action == AliasAuditLogAction.UpdateAlias.value
assert logs_for_alias[3].action == AliasAuditLogAction.DeleteAlias.value
def test_alias_audit_log_for_transfer():
original_user = create_new_user()
new_user = create_new_user()
alias = Alias.create(
user_id=original_user.id,
email=random_email(),
mailbox_id=original_user.default_mailbox_id,
)
transfer_alias(alias, new_user, [new_user.default_mailbox])
logs_for_alias = AliasAuditLog.filter_by(alias_id=alias.id).all()
assert len(logs_for_alias) == 3
assert logs_for_alias[0].action == AliasAuditLogAction.CreateAlias.value
assert logs_for_alias[1].action == AliasAuditLogAction.TransferredAlias.value
assert logs_for_alias[1].user_id == original_user.id
assert logs_for_alias[2].action == AliasAuditLogAction.AcceptTransferAlias.value
assert logs_for_alias[2].user_id == new_user.id

View File

@ -0,0 +1,70 @@
from typing import Tuple
from app.alias_audit_log_utils import AliasAuditLogAction
from app.alias_mailbox_utils import (
set_mailboxes_for_alias,
CannotSetMailboxesForAliasCause,
)
from app.models import Alias, Mailbox, User, AliasMailbox, AliasAuditLog
from tests.utils import create_new_user, random_email
def setup() -> Tuple[User, Alias]:
user = create_new_user()
alias = Alias.create(
user_id=user.id,
email=random_email(),
mailbox_id=user.default_mailbox_id,
commit=True,
)
return user, alias
def test_set_mailboxes_for_alias_empty_list():
user, alias = setup()
err = set_mailboxes_for_alias(user.id, alias, [])
assert err is CannotSetMailboxesForAliasCause.EmptyMailboxes
def test_set_mailboxes_for_alias_mailbox_for_other_user():
user, alias = setup()
another_user = create_new_user()
err = set_mailboxes_for_alias(user.id, alias, [another_user.default_mailbox_id])
assert err is CannotSetMailboxesForAliasCause.Forbidden
def test_set_mailboxes_for_alias_mailbox_not_exists():
user, alias = setup()
err = set_mailboxes_for_alias(user.id, alias, [9999999])
assert err is CannotSetMailboxesForAliasCause.Forbidden
def test_set_mailboxes_for_alias_mailbox_success():
user, alias = setup()
mb1 = Mailbox.create(
user_id=user.id,
email=random_email(),
verified=True,
)
mb2 = Mailbox.create(
user_id=user.id,
email=random_email(),
verified=True,
commit=True,
)
err = set_mailboxes_for_alias(user.id, alias, [mb1.id, mb2.id])
assert err is None
db_alias = Alias.get_by(id=alias.id)
assert db_alias is not None
assert db_alias.mailbox_id == mb1.id
alias_mailboxes = AliasMailbox.filter_by(alias_id=alias.id).all()
assert len(alias_mailboxes) == 1
assert alias_mailboxes[0].mailbox_id == mb2.id
audit_logs = AliasAuditLog.filter_by(alias_id=alias.id).all()
assert len(audit_logs) == 2
assert audit_logs[0].action == AliasAuditLogAction.CreateAlias.value
assert audit_logs[1].action == AliasAuditLogAction.ChangedMailboxes.value
assert audit_logs[1].message == f"{mb1.id} ({mb1.email}),{mb2.id} ({mb2.email})"

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,7 +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.models import Mailbox, MailboxActivation, User, Job from app.mailbox_utils import MailboxEmailChangeError, get_mailbox_for_reply_phase
from app.models import (
Mailbox,
MailboxActivation,
User,
Job,
UserAuditLog,
Alias,
AuthorizedAddress,
)
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
@ -48,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()
@ -218,7 +237,11 @@ def test_delete_with_transfer():
user, random_email(), use_digit_codes=True, send_link=False user, random_email(), use_digit_codes=True, send_link=False
).mailbox ).mailbox
transfer_mailbox = mailbox_utils.create_mailbox( transfer_mailbox = mailbox_utils.create_mailbox(
user, random_email(), use_digit_codes=True, send_link=False user,
random_email(),
use_digit_codes=True,
send_link=False,
verified=True,
).mailbox ).mailbox
mailbox_utils.delete_mailbox( mailbox_utils.delete_mailbox(
user, mailbox.id, transfer_mailbox_id=transfer_mailbox.id user, mailbox.id, transfer_mailbox_id=transfer_mailbox.id
@ -236,6 +259,28 @@ def test_delete_with_transfer():
assert job.payload["transfer_mailbox_id"] is None assert job.payload["transfer_mailbox_id"] is None
def test_cannot_delete_with_transfer_to_unverified_mailbox():
mailbox = mailbox_utils.create_mailbox(
user, random_email(), use_digit_codes=True, send_link=False
).mailbox
transfer_mailbox = mailbox_utils.create_mailbox(
user,
random_email(),
use_digit_codes=True,
send_link=False,
verified=False,
).mailbox
with pytest.raises(mailbox_utils.MailboxError):
mailbox_utils.delete_mailbox(
user, mailbox.id, transfer_mailbox_id=transfer_mailbox.id
)
# Verify mailbox still exists
db_mailbox = Mailbox.get_by(id=mailbox.id)
assert db_mailbox is not None
def test_verify_non_existing_mailbox(): def test_verify_non_existing_mailbox():
with pytest.raises(mailbox_utils.MailboxError): with pytest.raises(mailbox_utils.MailboxError):
mailbox_utils.verify_mailbox_code(user, 999999999, "9999999") mailbox_utils.verify_mailbox_code(user, 999999999, "9999999")
@ -258,6 +303,15 @@ def test_verify_other_users_mailbox():
mailbox_utils.verify_mailbox_code(user, mailbox.id, "9999999") mailbox_utils.verify_mailbox_code(user, mailbox.id, "9999999")
def test_verify_other_users_already_verified_mailbox():
other = create_new_user()
mailbox = Mailbox.create(
user_id=other.id, email=random_email(), verified=True, commit=True
)
with pytest.raises(mailbox_utils.MailboxError):
mailbox_utils.verify_mailbox_code(user, mailbox.id, "9999999")
@mail_sender.store_emails_test_decorator @mail_sender.store_emails_test_decorator
def test_verify_fail(): def test_verify_fail():
output = mailbox_utils.create_mailbox(user, random_email()) output = mailbox_utils.create_mailbox(user, random_email())
@ -277,10 +331,13 @@ def test_verify_too_may():
output = mailbox_utils.create_mailbox(user, random_email()) output = mailbox_utils.create_mailbox(user, random_email())
output.activation.tries = mailbox_utils.MAX_ACTIVATION_TRIES output.activation.tries = mailbox_utils.MAX_ACTIVATION_TRIES
Session.commit() Session.commit()
with pytest.raises(mailbox_utils.CannotVerifyError): try:
mailbox_utils.verify_mailbox_code( mailbox_utils.verify_mailbox_code(
user, output.mailbox.id, output.activation.code user, output.mailbox.id, output.activation.code
) )
assert False
except mailbox_utils.CannotVerifyError as e:
assert e.deleted_activation_code
@mail_sender.store_emails_test_decorator @mail_sender.store_emails_test_decorator
@ -302,3 +359,151 @@ def test_verify_ok():
assert activation is None assert activation is None
mailbox = Mailbox.get(id=output.mailbox.id) mailbox = Mailbox.get(id=output.mailbox.id)
assert mailbox.verified assert mailbox.verified
# perform_mailbox_email_change
def test_perform_mailbox_email_change_invalid_id():
res = mailbox_utils.perform_mailbox_email_change(99999)
assert res.error == MailboxEmailChangeError.InvalidId
assert res.message_category == "error"
def test_perform_mailbox_email_change_valid_id_not_new_email():
user = create_new_user()
mb = Mailbox.create(
user_id=user.id,
email=random_email(),
new_email=None,
verified=True,
commit=True,
)
res = mailbox_utils.perform_mailbox_email_change(mb.id)
assert res.error == MailboxEmailChangeError.InvalidId
assert res.message_category == "error"
audit_log_entries = UserAuditLog.filter_by(
user_id=user.id, action=UserAuditLogAction.UpdateMailbox.value
).count()
assert audit_log_entries == 0
def test_perform_mailbox_email_change_valid_id_email_already_used():
user = create_new_user()
new_email = random_email()
# Create mailbox with that email
Mailbox.create(
user_id=user.id,
email=new_email,
verified=True,
)
mb_to_change = Mailbox.create(
user_id=user.id,
email=random_email(),
new_email=new_email,
verified=True,
commit=True,
)
res = mailbox_utils.perform_mailbox_email_change(mb_to_change.id)
assert res.error == MailboxEmailChangeError.EmailAlreadyUsed
assert res.message_category == "error"
audit_log_entries = UserAuditLog.filter_by(
user_id=user.id, action=UserAuditLogAction.UpdateMailbox.value
).count()
assert audit_log_entries == 0
def test_perform_mailbox_email_change_success():
user = create_new_user()
new_email = random_email()
mb = Mailbox.create(
user_id=user.id,
email=random_email(),
new_email=new_email,
verified=True,
commit=True,
)
res = mailbox_utils.perform_mailbox_email_change(mb.id)
assert res.error is None
assert res.message_category == "success"
db_mailbox = Mailbox.get_by(id=mb.id)
assert db_mailbox is not None
assert db_mailbox.verified is True
assert db_mailbox.email == new_email
assert db_mailbox.new_email is None
audit_log_entries = UserAuditLog.filter_by(
user_id=user.id, action=UserAuditLogAction.UpdateMailbox.value
).count()
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

View File

@ -2,7 +2,7 @@ import arrow
from app.db import Session from app.db import Session
from app.models import CoinbaseSubscription from app.models import CoinbaseSubscription
from server import handle_coinbase_event from app.payments.coinbase import handle_coinbase_event
from tests.utils import create_new_user from tests.utils import create_new_user

View File

@ -1,113 +0,0 @@
import http.server
import json
import threading
import arrow
from app import config
from app.models import (
Subscription,
AppleSubscription,
CoinbaseSubscription,
ManualSubscription,
)
from tests.utils import create_new_user, random_token
from app.subscription_webhook import execute_subscription_webhook
http_server = None
last_http_request = None
def setup_module():
global http_server
http_server = http.server.ThreadingHTTPServer(("", 0), HTTPTestServer)
print(http_server.server_port)
threading.Thread(target=http_server.serve_forever, daemon=True).start()
config.SUBSCRIPTION_CHANGE_WEBHOOK = f"http://localhost:{http_server.server_port}"
def teardown_module():
global http_server
config.SUBSCRIPTION_CHANGE_WEBHOOK = None
http_server.shutdown()
class HTTPTestServer(http.server.BaseHTTPRequestHandler):
def do_POST(self):
global last_http_request
content_len = int(self.headers.get("Content-Length"))
body_data = self.rfile.read(content_len)
last_http_request = json.loads(body_data)
self.send_response(200)
def test_webhook_with_trial():
user = create_new_user()
execute_subscription_webhook(user)
assert last_http_request["user_id"] == user.id
assert last_http_request["is_premium"]
assert last_http_request["active_subscription_end"] is None
def test_webhook_with_subscription():
user = create_new_user()
end_at = arrow.utcnow().shift(days=1).replace(hour=0, minute=0, second=0)
Subscription.create(
user_id=user.id,
cancel_url="",
update_url="",
subscription_id=random_token(10),
event_time=arrow.now(),
next_bill_date=end_at.date(),
plan="yearly",
flush=True,
)
execute_subscription_webhook(user)
assert last_http_request["user_id"] == user.id
assert last_http_request["is_premium"]
assert last_http_request["active_subscription_end"] == end_at.timestamp
def test_webhook_with_apple_subscription():
user = create_new_user()
end_at = arrow.utcnow().shift(days=2).replace(hour=0, minute=0, second=0)
AppleSubscription.create(
user_id=user.id,
receipt_data=arrow.now().date().strftime("%Y-%m-%d"),
expires_date=end_at.date().strftime("%Y-%m-%d"),
original_transaction_id=random_token(10),
plan="yearly",
product_id="",
flush=True,
)
execute_subscription_webhook(user)
assert last_http_request["user_id"] == user.id
assert last_http_request["is_premium"]
assert last_http_request["active_subscription_end"] == end_at.timestamp
def test_webhook_with_coinbase_subscription():
user = create_new_user()
end_at = arrow.utcnow().shift(days=3).replace(hour=0, minute=0, second=0)
CoinbaseSubscription.create(
user_id=user.id, end_at=end_at.date().strftime("%Y-%m-%d"), flush=True
)
execute_subscription_webhook(user)
assert last_http_request["user_id"] == user.id
assert last_http_request["is_premium"]
assert last_http_request["active_subscription_end"] == end_at.timestamp
def test_webhook_with_manual_subscription():
user = create_new_user()
end_at = arrow.utcnow().shift(days=3).replace(hour=0, minute=0, second=0)
ManualSubscription.create(
user_id=user.id, end_at=end_at.date().strftime("%Y-%m-%d"), flush=True
)
execute_subscription_webhook(user)
assert last_http_request["user_id"] == user.id
assert last_http_request["is_premium"]
assert last_http_request["active_subscription_end"] == end_at.timestamp

Some files were not shown because too many files have changed in this diff Show More