diff --git a/app/account_linking.py b/app/account_linking.py index 096d2d35..f886453d 100644 --- a/app/account_linking.py +++ b/app/account_linking.py @@ -2,13 +2,25 @@ from abc import ABC, abstractmethod from arrow import Arrow from dataclasses import dataclass from enum import Enum + +from flask import url_for from newrelic import agent from typing import Optional +from app import config from app.db import Session +from app.email_utils import send_email_at_most_times, render from app.errors import AccountAlreadyLinkedToAnotherPartnerException from app.log import LOG -from app.models import PartnerSubscription, Partner, PartnerUser, User +from app.models import ( + PartnerSubscription, + Partner, + PartnerUser, + User, + AppleSubscription, + Subscription, +) +from app.proton.utils import is_proton_partner from app.utils import random_string @@ -38,6 +50,36 @@ class LinkResult: strategy: str +def send_double_subscription_if_needed(partner_user: PartnerUser): + sub = partner_user.user.get_active_subscription() + if isinstance(sub, AppleSubscription): + channel = "Apple" + elif isinstance(sub, Subscription): + channel = "Paddle" + else: + return + send_email_at_most_times( + partner_user.user, + config.ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER, + partner_user.user.email, + f"You have two subscriptions in SimpleLogin", + render( + "transactional/double-subscription-partner.txt.jinja2", + is_proton=is_proton_partner(partner_user.partner), + partner=partner_user.partner, + subscription_channel=channel, + cancel_link=url_for("dashboard.billing"), + ), + render( + "transactional/double-subscription-partner.html", + is_proton=is_proton_partner(partner_user.partner), + partner=partner_user.partner, + subscription_channel=channel, + cancel_link=url_for("dashboard.billing"), + ), + ) + + def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan): sub = PartnerSubscription.get_by(partner_user_id=partner_user.id) if plan.type == SLPlanType.Free: @@ -66,6 +108,7 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan): "PlanChange", {"plan": "premium", "type": "extension"} ) sub.end_at = plan.expiration + send_double_subscription_if_needed(partner_user) Session.commit() diff --git a/app/admin_model.py b/app/admin_model.py index 3351641e..a3c4122e 100644 --- a/app/admin_model.py +++ b/app/admin_model.py @@ -219,7 +219,7 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool): flash(f"user {user} already has a lifetime license", "warning") continue - sub: Subscription = user.get_subscription() + sub: Subscription = user.get_paddle_subscription() if sub and not sub.cancelled: flash( f"user {user} already has a Paddle license, they have to cancel it first", diff --git a/app/auth/views/proton.py b/app/auth/views/proton.py index 53ab5dbd..6d82384f 100644 --- a/app/auth/views/proton.py +++ b/app/auth/views/proton.py @@ -19,8 +19,8 @@ from app.proton.proton_client import HttpProtonClient, convert_access_token from app.proton.proton_callback_handler import ( ProtonCallbackHandler, Action, - get_proton_partner, ) +from app.proton.utils import get_proton_partner from app.utils import sanitize_next_url _authorization_base_url = PROTON_BASE_URL + "/oauth/authorize" diff --git a/app/config.py b/app/config.py index 558f7c24..d27cf58c 100644 --- a/app/config.py +++ b/app/config.py @@ -351,6 +351,8 @@ ALERT_COMPLAINT_TRANSACTIONAL_PHASE = "alert_complaint_transactional_phase" ALERT_QUARANTINE_DMARC = "alert_quarantine_dmarc" +ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER = "alert_dual_sub_with_partner" + # <<<<< END ALERT EMAIL >>>> # Disable onboarding emails diff --git a/app/dashboard/views/billing.py b/app/dashboard/views/billing.py index a0fa87ee..c3ca4337 100644 --- a/app/dashboard/views/billing.py +++ b/app/dashboard/views/billing.py @@ -13,7 +13,7 @@ from app.paddle_utils import cancel_subscription, change_plan @login_required def billing(): # sanity check: make sure this page is only for user who has paddle subscription - sub: Subscription = current_user.get_subscription() + sub: Subscription = current_user.get_paddle_subscription() if not sub: flash("You don't have any active subscription", "warning") diff --git a/app/dashboard/views/coupon.py b/app/dashboard/views/coupon.py index 3afe1e19..f022a4d7 100644 --- a/app/dashboard/views/coupon.py +++ b/app/dashboard/views/coupon.py @@ -40,7 +40,7 @@ def coupon_route(): if current_user.lifetime: can_use_coupon = False - sub: Subscription = current_user.get_subscription() + sub: Subscription = current_user.get_paddle_subscription() if sub: can_use_coupon = False diff --git a/app/dashboard/views/delete_account.py b/app/dashboard/views/delete_account.py index 560bbaa7..42c964ef 100644 --- a/app/dashboard/views/delete_account.py +++ b/app/dashboard/views/delete_account.py @@ -14,7 +14,7 @@ from app.models import Subscription, Job @sudo_required def delete_account(): if request.method == "POST" and request.form.get("form-name") == "delete-account": - sub: Subscription = current_user.get_subscription() + sub: Subscription = current_user.get_paddle_subscription() # user who has canceled can also re-subscribe if sub and not sub.cancelled: flash("Please cancel your current subscription first", "warning") diff --git a/app/dashboard/views/lifetime_licence.py b/app/dashboard/views/lifetime_licence.py index c348bead..2fc4c56b 100644 --- a/app/dashboard/views/lifetime_licence.py +++ b/app/dashboard/views/lifetime_licence.py @@ -23,7 +23,7 @@ def lifetime_licence(): # user needs to cancel active subscription first # to avoid being charged - sub = current_user.get_subscription() + sub = current_user.get_paddle_subscription() if sub and not sub.cancelled: flash("Please cancel your current subscription first", "warning") return redirect(url_for("dashboard.index")) diff --git a/app/dashboard/views/pricing.py b/app/dashboard/views/pricing.py index a316ec3e..7bd6334d 100644 --- a/app/dashboard/views/pricing.py +++ b/app/dashboard/views/pricing.py @@ -29,7 +29,7 @@ def pricing(): flash("You already have a lifetime subscription", "error") return redirect(url_for("dashboard.index")) - sub: Subscription = current_user.get_subscription() + sub: Subscription = current_user.get_paddle_subscription() # user who has canceled can re-subscribe if sub and not sub.cancelled: flash("You already have an active subscription", "error") diff --git a/app/dashboard/views/setting.py b/app/dashboard/views/setting.py index 782be0ca..e20b079c 100644 --- a/app/dashboard/views/setting.py +++ b/app/dashboard/views/setting.py @@ -49,8 +49,7 @@ from app.models import ( AppleSubscription, PartnerUser, ) -from app.proton.utils import is_connect_with_proton_enabled -from app.proton.proton_callback_handler import get_proton_partner +from app.proton.utils import is_connect_with_proton_enabled, get_proton_partner from app.utils import random_string, sanitize_email diff --git a/app/models.py b/app/models.py index e5237263..6ccc734f 100644 --- a/app/models.py +++ b/app/models.py @@ -8,7 +8,7 @@ import os import random import uuid from email.utils import formataddr -from typing import List, Tuple, Optional +from typing import List, Tuple, Optional, Union import arrow import sqlalchemy as sa @@ -597,57 +597,59 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): return user + def get_active_subscription( + self, + ) -> Optional[ + Union[ + Subscription + | AppleSubscription + | ManualSubscription + | CoinbaseSubscription + | PartnerSubscription + ] + ]: + sub: Subscription = self.get_paddle_subscription() + if sub: + return sub + + apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id) + if apple_sub and apple_sub.is_valid(): + return apple_sub + + manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id) + if manual_sub and manual_sub.is_active(): + return manual_sub + + coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by( + user_id=self.id + ) + if coinbase_subscription and coinbase_subscription.is_active(): + return coinbase_subscription + + partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(self.id) + if partner_sub and partner_sub.is_active(): + return partner_sub + + return None + # region Billing def lifetime_or_active_subscription(self) -> bool: """True if user has lifetime licence or active subscription""" if self.lifetime: return True - sub: Subscription = self.get_subscription() - if sub: - return True - - apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id) - if apple_sub and apple_sub.is_valid(): - return True - - manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id) - if manual_sub and manual_sub.is_active(): - return True - - coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by( - user_id=self.id - ) - if coinbase_subscription and coinbase_subscription.is_active(): - return True - - partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(self.id) - if partner_sub and partner_sub.is_active(): - return True - - return False + return self.get_active_subscription() is not None def is_paid(self) -> bool: """same as _lifetime_or_active_subscription but not include free manual subscription""" - sub: Subscription = self.get_subscription() - if sub: - return True + sub = self.get_active_subscription() + if sub is None: + return False - apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id) - if apple_sub and apple_sub.is_valid(): - return True + if isinstance(sub, ManualSubscription) and sub.is_giveaway(): + return False - manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id) - if manual_sub and not manual_sub.is_giveaway and manual_sub.is_active(): - return True - - coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by( - user_id=self.id - ) - if coinbase_subscription and coinbase_subscription.is_active(): - return True - - return False + return True def in_trial(self): """return True if user does not have lifetime licence or an active subscription AND is in trial period""" @@ -665,7 +667,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): if self.lifetime_or_active_subscription(): # user who has canceled can also re-subscribe - sub: Subscription = self.get_subscription() + sub: Subscription = self.get_paddle_subscription() if sub and sub.cancelled: return True @@ -696,7 +698,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): if self.lifetime: channels.append("Lifetime") - sub: Subscription = self.get_subscription() + sub: Subscription = self.get_paddle_subscription() if sub: if sub.cancelled: channels.append( @@ -779,7 +781,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): names = self.name.split(" ") return "".join([n[0].upper() for n in names if n]) - def get_subscription(self) -> Optional["Subscription"]: + def get_paddle_subscription(self) -> Optional["Subscription"]: """return *active* Paddle subscription Return None if the subscription is already expired TODO: support user unsubscribe and re-subscribe @@ -3165,6 +3167,7 @@ class PartnerUser(Base, ModelMixin): partner_email = sa.Column(sa.String(255), unique=False, nullable=True) user = orm.relationship(User, foreign_keys=[user_id]) + partner = orm.relationship(Partner, foreign_keys=[partner_id]) __table_args__ = ( sa.UniqueConstraint( diff --git a/app/proton/proton_callback_handler.py b/app/proton/proton_callback_handler.py index 930a44de..dd504f6d 100644 --- a/app/proton/proton_callback_handler.py +++ b/app/proton/proton_callback_handler.py @@ -3,8 +3,7 @@ from enum import Enum from flask import url_for from typing import Optional -from app.db import Session -from app.errors import LinkException, ProtonPartnerNotSetUp +from app.errors import LinkException from app.models import User, Partner from app.proton.proton_client import ProtonClient, ProtonUser from app.account_linking import ( @@ -13,20 +12,6 @@ from app.account_linking import ( PartnerLinkRequest, ) -PROTON_PARTNER_NAME = "Proton" -_PROTON_PARTNER: Optional[Partner] = None - - -def get_proton_partner() -> Partner: - global _PROTON_PARTNER - if _PROTON_PARTNER is None: - partner = Partner.get_by(name=PROTON_PARTNER_NAME) - if partner is None: - raise ProtonPartnerNotSetUp - Session.expunge(partner) - _PROTON_PARTNER = partner - return _PROTON_PARTNER - class Action(Enum): Login = 1 diff --git a/app/proton/utils.py b/app/proton/utils.py index d8421076..fea2c334 100644 --- a/app/proton/utils.py +++ b/app/proton/utils.py @@ -1,6 +1,30 @@ +from typing import Optional + from app.config import CONNECT_WITH_PROTON, CONNECT_WITH_PROTON_COOKIE_NAME from flask import request +from app.db import Session +from app.errors import ProtonPartnerNotSetUp +from app.models import Partner + +PROTON_PARTNER_NAME = "Proton" +_PROTON_PARTNER: Optional[Partner] = None + + +def get_proton_partner() -> Partner: + global _PROTON_PARTNER + if _PROTON_PARTNER is None: + partner = Partner.get_by(name=PROTON_PARTNER_NAME) + if partner is None: + raise ProtonPartnerNotSetUp + Session.expunge(partner) + _PROTON_PARTNER = partner + return _PROTON_PARTNER + + +def is_proton_partner(partner: Partner) -> str: + return partner.name == PROTON_PARTNER_NAME + def is_connect_with_proton_enabled() -> bool: if CONNECT_WITH_PROTON: diff --git a/cron.py b/cron.py index 0c64eac4..bb2ee4e5 100644 --- a/cron.py +++ b/cron.py @@ -170,7 +170,7 @@ def notify_manual_sub_end(): LOG.d("%s has a lifetime licence", user) continue - paddle_sub: Subscription = user.get_subscription() + paddle_sub: Subscription = user.get_paddle_subscription() if paddle_sub and not paddle_sub.cancelled: LOG.d("%s has an active Paddle subscription", user) continue @@ -179,7 +179,7 @@ def notify_manual_sub_end(): # user can have a (free) manual subscription but has taken a paid subscription via # Paddle, Coinbase or Apple since then if manual_sub.is_giveaway: - if user.get_subscription(): + if user.get_paddle_subscription(): LOG.d("%s has a active Paddle subscription", user) continue diff --git a/init_app.py b/init_app.py index 331b8f38..39ab099c 100644 --- a/init_app.py +++ b/init_app.py @@ -6,7 +6,7 @@ from app.db import Session from app.log import LOG from app.models import Mailbox, Contact, SLDomain, Partner from app.pgp_utils import load_public_key -from app.proton.proton_callback_handler import PROTON_PARTNER_NAME +from app.proton.utils import PROTON_PARTNER_NAME from server import create_light_app diff --git a/templates/emails/transactional/double-subscription-partner.html b/templates/emails/transactional/double-subscription-partner.html new file mode 100644 index 00000000..e8a379d6 --- /dev/null +++ b/templates/emails/transactional/double-subscription-partner.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block content %} + {% call text() %} + Hi, + {% endcall %} + + {% call text() %} + {% if is_proton %} + As a Proton user with a paid mail subscription you can have SimpleLogin premium for free, thanks to the SimpleLogin Proton integration.
+ {% else %} + {{ partner.name }} has upgraded your account to premium.
+ {% endif %} + You currently have a paid SimpleLogin subscription via {{ subscription_channel }}.
+ {% if subscription_channel == 'apple' %} + You can cancel it to avoid automatic renewal on your Apple iCloud account page. + You can find more info here.
+ {% elif subscription_channel == 'paddle' %} + You can cancel it to avoid automatic renewal on {{ cancel_link }}.
+ {% endif %} + You can also keep both subscriptions. In this case there’s nothing to do. + {% endcall %} + + {% call text() %} + Best,
+ SimpleLogin team. + {% endcall %} +{% endblock %} diff --git a/templates/emails/transactional/double-subscription-partner.txt.jinja2 b/templates/emails/transactional/double-subscription-partner.txt.jinja2 new file mode 100644 index 00000000..9ad89171 --- /dev/null +++ b/templates/emails/transactional/double-subscription-partner.txt.jinja2 @@ -0,0 +1,22 @@ +{% extends "base.txt.jinja2" %} + +{% block content %} +Hi, + +{% if is_proton -%} +As a Proton user with a paid mail subscription you can have SimpleLogin premium for free, thanks to the SimpleLogin Proton integration. +{% else -%}} +{{ partner.name }} has upgraded your account to premium. +{% endif -%} +You currently have a paid SimpleLogin subscription via {subscription_channel}. +{% if subscription_channel == 'Apple' %} +You can cancel it to avoid automatic renewal on your Apple iCloud account page. +Please find more info at https://support.apple.com/en-us/HT202039 +{% elif subscription_channel == 'Paddle' %} +You can cancel it to avoid automatic renewal on {{ cancel_link }}. +{% endif %} +You can also keep both subscriptions. In this case there’s nothing to do. + +Best, +SimpleLogin team. +{% endblock %} diff --git a/tests/email_tests/test_rate_limit.py b/tests/email_tests/test_rate_limit.py index e08573c7..b5f464a2 100644 --- a/tests/email_tests/test_rate_limit.py +++ b/tests/email_tests/test_rate_limit.py @@ -1,3 +1,5 @@ +import random + from app.config import ( MAX_ACTIVITY_DURING_MINUTE_PER_ALIAS, MAX_ACTIVITY_DURING_MINUTE_PER_MAILBOX, @@ -88,11 +90,12 @@ def test_rate_limited_reply_phase(flask_client): alias = Alias.create_new_random(user) Session.commit() + reply_email = f"reply-{random.random()}@sl.local" contact = Contact.create( user_id=user.id, alias_id=alias.id, website_email="contact@example.com", - reply_email="rep@sl.local", + reply_email=reply_email, ) Session.commit() for _ in range(MAX_ACTIVITY_DURING_MINUTE_PER_ALIAS + 1): @@ -103,4 +106,4 @@ def test_rate_limited_reply_phase(flask_client): ) Session.commit() - assert rate_limited_reply_phase("rep@sl.local") + assert rate_limited_reply_phase(reply_email) diff --git a/tests/proton/test_proton_callback_handler.py b/tests/proton/test_proton_callback_handler.py index 752a874c..fd7f8bbd 100644 --- a/tests/proton/test_proton_callback_handler.py +++ b/tests/proton/test_proton_callback_handler.py @@ -6,10 +6,10 @@ from app.account_linking import ( from app.proton.proton_client import ProtonClient, UserInformation from app.proton.proton_callback_handler import ( ProtonCallbackHandler, - get_proton_partner, generate_account_not_allowed_to_log_in, ) from app.models import User, PartnerUser +from app.proton.utils import get_proton_partner from app.utils import random_string from typing import Optional from tests.utils import random_email diff --git a/tests/test_account_linking.py b/tests/test_account_linking.py index 5cebb108..c71804dc 100644 --- a/tests/test_account_linking.py +++ b/tests/test_account_linking.py @@ -1,6 +1,10 @@ +import random + +import arrow import pytest from arrow import Arrow +from app import config from app.account_linking import ( process_link_case, process_login_case, @@ -13,11 +17,13 @@ from app.account_linking import ( SLPlanType, PartnerLinkRequest, ClientMergeStrategy, + set_plan_for_partner_user, ) -from app.proton.proton_callback_handler import get_proton_partner +from app.mail_sender import mail_sender from app.db import Session from app.errors import AccountAlreadyLinkedToAnotherPartnerException -from app.models import Partner, PartnerUser, User +from app.models import Partner, PartnerUser, User, Subscription, PlanEnum, SentAlert +from app.proton.utils import get_proton_partner from app.utils import random_string from tests.utils import random_email @@ -320,3 +326,39 @@ def test_ensure_partner_user_exists_for_user_raises_exception_when_linked_to_ano user, partner_2, ) + + +@mail_sender.store_emails_test_decorator +def test_send_double_sub_email_is_sent(flask_client): + user = create_user(random_email()) + Subscription.create( + user_id=user.id, + cancel_url="https://checkout.paddle.com/subscription/cancel?user=1234", + update_url="https://checkout.paddle.com/subscription/update?user=1234", + subscription_id=int(random.randint(10000, 999999999)), + event_time=arrow.now(), + next_bill_date=arrow.now().shift(days=10).date(), + plan=PlanEnum.monthly, + commit=True, + ) + partner = Partner.create( + name=random_string(), + contact_email=random_email(), + flush=True, + ) + partner_user = PartnerUser.create( + user_id=user.id, + partner_id=partner.id, + partner_email=user.email, + external_user_id=random_string(), + commit=True, + ) + set_plan_for_partner_user( + partner_user, + SLPlan(type=SLPlanType.Premium, expiration=arrow.now().shift(months=10)), + ) + emails_sent = mail_sender.get_stored_emails() + assert len(emails_sent) == 1 + alerts = SentAlert.filter_by(user_id=user.id).all() + assert len(alerts) == 1 + assert alerts[0].alert_type == config.ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER diff --git a/tests/test_models.py b/tests/test_models.py index 586eb15c..8fe4ea6a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -278,12 +278,12 @@ def test_user_get_subscription_grace_period(flask_client): commit=True, ) - assert user.get_subscription() is not None + assert user.get_paddle_subscription() is not None sub.next_bill_date = ( arrow.now().shift(days=-(PADDLE_SUBSCRIPTION_GRACE_DAYS + 1)).date() ) - assert user.get_subscription() is None + assert user.get_paddle_subscription() is None def test_create_contact_for_noreply(flask_client):