From 3c77f8af4b278771a3bacf97d25f75e908cc5a69 Mon Sep 17 00:00:00 2001 From: MrMeeb Date: Fri, 29 Nov 2024 12:00:12 +0000 Subject: [PATCH] 4.61.0 --- app/app/account_linking.py | 17 ++- app/app/email_utils.py | 17 ++- app/app/mailbox_utils.py | 64 ++++++++++- app/app/models.py | 7 +- app/app/partner_user_utils.py | 4 +- app/app/proton/proton_client.py | 6 +- app/email_handler.py | 36 ++----- app/job_runner.py | 2 + app/local_data/words.txt | 53 ---------- .../versions/2024_112619_085f77996ce3_.py | 35 ++++++ app/templates/dashboard/pricing.html | 39 +++---- app/templates/dashboard/setting.html | 9 +- app/tests/jobs/test_delete_mailbox_job.py | 18 ++++ app/tests/proton/test_account_linking.py | 100 ++++++++++++++++++ app/tests/test_email_handler.py | 24 ----- app/tests/test_email_utils.py | 15 ++- app/tests/test_mailbox_utils.py | 93 +++++++++++++++- 17 files changed, 385 insertions(+), 154 deletions(-) create mode 100644 app/migrations/versions/2024_112619_085f77996ce3_.py create mode 100644 app/tests/proton/test_account_linking.py diff --git a/app/app/account_linking.py b/app/app/account_linking.py index 2f42aa0..bd66a95 100644 --- a/app/app/account_linking.py +++ b/app/app/account_linking.py @@ -4,6 +4,7 @@ from enum import Enum from typing import Optional import arrow +import sqlalchemy.exc from arrow import Arrow from newrelic import agent from psycopg2.errors import UniqueViolation @@ -35,6 +36,7 @@ from app.utils import random_string class SLPlanType(Enum): Free = 1 Premium = 2 + PremiumLifetime = 3 @dataclass @@ -75,6 +77,7 @@ def send_user_plan_changed_event(partner_user: PartnerUser) -> Optional[int]: def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan): sub = PartnerSubscription.get_by(partner_user_id=partner_user.id) + is_lifetime = plan.type == SLPlanType.PremiumLifetime if plan.type == SLPlanType.Free: if sub is not None: LOG.i( @@ -83,25 +86,30 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan): PartnerSubscription.delete(sub.id) agent.record_custom_event("PlanChange", {"plan": "free"}) else: + end_time = plan.expiration + if plan.type == SLPlanType.PremiumLifetime: + end_time = None if sub is None: LOG.i( f"Creating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]" ) create_partner_subscription( partner_user=partner_user, - expiration=plan.expiration, + expiration=end_time, + lifetime=is_lifetime, msg="Upgraded via partner. User did not have a previous partner subscription", ) agent.record_custom_event("PlanChange", {"plan": "premium", "type": "new"}) 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( "PlanChange", {"plan": "premium", "type": "extension"} ) - sub.end_at = plan.expiration + sub.end_at = plan.expiration if not is_lifetime else None + sub.lifetime = is_lifetime emit_user_audit_log( user=partner_user.user, action=UserAuditLogAction.SubscriptionExtended, @@ -185,7 +193,8 @@ class NewUserStrategy(ClientMergeStrategy): user=new_user, strategy=self.__class__.__name__, ) - except UniqueViolation: + 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): diff --git a/app/app/email_utils.py b/app/app/email_utils.py index 742806d..221f7d6 100644 --- a/app/app/email_utils.py +++ b/app/app/email_utils.py @@ -1345,17 +1345,16 @@ def get_queue_id(msg: Message) -> Optional[str]: received_header = str(msg[headers.RECEIVED]) 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 ; Fri, 4 Jun 2021 14:55:43 +0000 (UTC)' - search_result = re.search("with ESMTPS id [0-9a-zA-Z]{1,}", received_header) - if not search_result: - return - - # the "with ESMTPS id 4FxQmw1DXdz2vK2" part - with_esmtps = received_header[search_result.start() : search_result.end()] - - return with_esmtps[len("with ESMTPS id ") :] + search_result = re.search(r"with E?SMTP[AS]? id ([0-9a-zA-Z]{1,})", received_header) + if search_result: + return search_result.group(1) + search_result = re.search("\(Postfix\)\r\n\tid ([a-zA-Z0-9]{1,});", received_header) + if search_result: + return search_result.group(1) + return None def should_ignore_bounce(mail_from: str) -> bool: diff --git a/app/app/mailbox_utils.py b/app/app/mailbox_utils.py index 4b77746..3cbb030 100644 --- a/app/app/mailbox_utils.py +++ b/app/app/mailbox_utils.py @@ -12,11 +12,13 @@ from app.email_utils import ( email_can_be_used_as_mailbox, send_email, render, + get_email_domain_part, ) from app.email_validation import is_valid_email 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 @@ -52,6 +54,7 @@ def create_mailbox( use_digit_codes: bool = False, send_link: bool = True, ) -> CreateMailboxOutput: + email = sanitize_email(email) if not user.is_premium(): LOG.i( f"User {user} has tried to create mailbox with {email} but is not premium" @@ -104,7 +107,10 @@ def create_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.get(mailbox_id) @@ -150,6 +156,7 @@ def delete_mailbox( "transfer_mailbox_id": transfer_mailbox_id if transfer_mailbox_id and transfer_mailbox_id > 0 else None, + "send_mail": send_mail, }, run_at=arrow.now(), commit=True, @@ -328,3 +335,56 @@ def perform_mailbox_email_change(mailbox_id: int) -> MailboxEmailChangeResult: 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) diff --git a/app/app/models.py b/app/app/models.py index 1201338..8186a7e 100644 --- a/app/app/models.py +++ b/app/app/models.py @@ -3778,7 +3778,8 @@ class PartnerSubscription(Base, ModelMixin): ) # 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) @@ -3800,7 +3801,9 @@ class PartnerSubscription(Base, ModelMixin): return None 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 diff --git a/app/app/partner_user_utils.py b/app/app/partner_user_utils.py index ba665f0..6254ba6 100644 --- a/app/app/partner_user_utils.py +++ b/app/app/partner_user_utils.py @@ -33,12 +33,14 @@ def create_partner_user( def create_partner_subscription( partner_user: PartnerUser, - expiration: Optional[Arrow], + 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" diff --git a/app/app/proton/proton_client.py b/app/app/proton/proton_client.py index f06325b..8c086ec 100644 --- a/app/app/proton/proton_client.py +++ b/app/app/proton/proton_client.py @@ -16,6 +16,7 @@ PROTON_ERROR_CODE_HV_NEEDED = 9001 PLAN_FREE = 1 PLAN_PREMIUM = 2 +PLAN_PREMIUM_LIFETIME = 3 @dataclass @@ -112,10 +113,13 @@ class HttpProtonClient(ProtonClient): if plan_value == PLAN_FREE: plan = SLPlan(type=SLPlanType.Free, expiration=None) elif plan_value == PLAN_PREMIUM: + expiration = info.get("Expiration", "1") plan = SLPlan( 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: raise Exception(f"Invalid value for plan: {plan_value}") diff --git a/app/email_handler.py b/app/email_handler.py index 59f203b..9eb88ba 100644 --- a/app/email_handler.py +++ b/app/email_handler.py @@ -149,6 +149,7 @@ from app.handler.unsubscribe_generator import UnsubscribeGenerator from app.handler.unsubscribe_handler import UnsubscribeHandler from app.log import LOG, set_message_id 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.models import ( Alias, @@ -172,7 +173,7 @@ from app.pgp_utils import ( sign_data, 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 server import create_light_app @@ -1008,7 +1009,6 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): return False, status.E503 user = alias.user - mail_from = envelope.mail_from if not user.can_send_or_receive(): LOG.i(f"User {user} cannot send emails") @@ -1022,13 +1022,15 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): return False, dmarc_delivery_status # 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 alias.disable_email_spoofing_check: # ignore this error, use default alias mailbox LOG.w( "ignore unknown sender to reverse-alias %s: %s -> %s", - mail_from, + envelope.mail_from, alias, contact, ) @@ -1367,32 +1369,6 @@ def replace_original_message_id(alias: Alias, email_log: EmailLog, msg: Message) 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( envelope, msg, reply_email: str, user: User, alias: Alias, contact: Contact ): diff --git a/app/job_runner.py b/app/job_runner.py index 89d6f2d..af99fe8 100644 --- a/app/job_runner.py +++ b/app/job_runner.py @@ -164,6 +164,8 @@ def delete_mailbox_job(job: Job): Session.commit() LOG.d("Mailbox %s %s deleted", mailbox_id, mailbox_email) + if not job.payload.get("send_mail", True): + return if alias_transferred_to: send_email( user.email, diff --git a/app/local_data/words.txt b/app/local_data/words.txt index 475c7fa..4ea7e67 100644 --- a/app/local_data/words.txt +++ b/app/local_data/words.txt @@ -1,6 +1,4 @@ abacus -abdomen -abdominal abide abiding ability @@ -1031,7 +1029,6 @@ chosen chowder chowtime chrome -chubby chuck chug chummy @@ -2041,8 +2038,6 @@ dwindling dynamic dynamite dynasty -dyslexia -dyslexic each eagle earache @@ -2081,7 +2076,6 @@ eatery eating eats ebay -ebony ebook ecard eccentric @@ -2375,8 +2369,6 @@ exclude excluding exclusion exclusive -excretion -excretory excursion excusable excusably @@ -2396,8 +2388,6 @@ existing exit exodus exonerate -exorcism -exorcist expand expanse expansion @@ -2483,7 +2473,6 @@ fanning fantasize fantastic fantasy -fascism fastball faster fasting @@ -3028,7 +3017,6 @@ guiding guileless guise gulf -gullible gully gulp gumball @@ -3040,10 +3028,6 @@ gurgle gurgling guru gush -gusto -gusty -gutless -guts gutter guy guzzler @@ -3242,8 +3226,6 @@ humble humbling humbly humid -humiliate -humility humming hummus humongous @@ -3271,7 +3253,6 @@ hurray hurricane hurried hurry -hurt husband hush husked @@ -3292,8 +3273,6 @@ hypnotic hypnotism hypnotist hypnotize -hypocrisy -hypocrite ibuprofen ice iciness @@ -3323,7 +3302,6 @@ image imaginary imagines imaging -imbecile imitate imitation immerse @@ -3746,7 +3724,6 @@ machine machinist magazine magenta -maggot magical magician magma @@ -3968,8 +3945,6 @@ multitude mumble mumbling mumbo -mummified -mummify mumps munchkin mundane @@ -4022,8 +3997,6 @@ napped napping nappy narrow -nastily -nastiness national native nativity @@ -4446,7 +4419,6 @@ pasta pasted pastel pastime -pastor pastrami pasture pasty @@ -4458,7 +4430,6 @@ path patience patient patio -patriarch patriot patrol patronage @@ -4549,7 +4520,6 @@ pettiness petty petunia phantom -phobia phoenix phonebook phoney @@ -4608,7 +4578,6 @@ plot plow ploy pluck -plug plunder plunging plural @@ -4875,7 +4844,6 @@ pupil puppet puppy purchase -pureblood purebred purely pureness @@ -5047,7 +5015,6 @@ recharger recipient recital recite -reckless reclaim recliner reclining @@ -5440,7 +5407,6 @@ rubdown ruby ruckus rudder -rug ruined rule rumble @@ -5448,7 +5414,6 @@ rumbling rummage rumor runaround -rundown runner running runny @@ -5518,7 +5483,6 @@ sandpaper sandpit sandstone sandstorm -sandworm sandy sanitary sanitizer @@ -5541,7 +5505,6 @@ satisfy saturate saturday sauciness -saucy sauna savage savanna @@ -5552,7 +5515,6 @@ savor saxophone say scabbed -scabby scalded scalding scale @@ -5587,7 +5549,6 @@ science scientist scion scoff -scolding scone scoop scooter @@ -5651,8 +5612,6 @@ sedate sedation sedative sediment -seduce -seducing segment seismic seizing @@ -5899,7 +5858,6 @@ skimpily skincare skinless skinning -skinny skintight skipper skipping @@ -6248,17 +6206,12 @@ stifle stifling stillness stilt -stimulant -stimulate -stimuli stimulus stinger stingily stinging stingray stingy -stinking -stinky stipend stipulate stir @@ -6866,7 +6819,6 @@ unbent unbiased unbitten unblended -unblessed unblock unbolted unbounded @@ -6947,7 +6899,6 @@ undertone undertook undertow underuse -underwear underwent underwire undesired @@ -7031,7 +6982,6 @@ uninsured uninvited union uniquely -unisexual unison unissued unit @@ -7492,8 +7442,6 @@ wheat whenever whiff whimsical -whinny -whiny whisking whoever whole @@ -7599,7 +7547,6 @@ wrongness wrought xbox xerox -yahoo yam yanking yapping diff --git a/app/migrations/versions/2024_112619_085f77996ce3_.py b/app/migrations/versions/2024_112619_085f77996ce3_.py new file mode 100644 index 0000000..2aba0ee --- /dev/null +++ b/app/migrations/versions/2024_112619_085f77996ce3_.py @@ -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 ### diff --git a/app/templates/dashboard/pricing.html b/app/templates/dashboard/pricing.html index edbf2f9..8ba1633 100644 --- a/app/templates/dashboard/pricing.html +++ b/app/templates/dashboard/pricing.html @@ -57,31 +57,21 @@ {% endblock %} {% block default_content %} - {% if NOW.timestamp < 1701475201 %} + {% if NOW.timestamp < 1733184000 %} -
- Black Friday Deal: 33% off on the yearly plan for the first year ($20 instead of $30). +
+ Lifetime deal for SimpleLogin Premium and Proton Pass Plus for $199 + Buy now
- Please use this coupon code - BF2023 during the checkout. -
- -
- Available until December 1, 2023. + Available until December 3, 2024.
{% endif %}
-
+

Upgrade to unlock premium features

-
- new SimpleLogin Premium now includes Proton Pass premium features. - Learn more ↗ -
{% if manual_sub %}
@@ -131,6 +121,11 @@ aria-selected="true">YearlySave $18
+
+ new SimpleLogin Premium now includes Proton Pass premium features. + Learn more ↗ +
-
Proton plan
+
Proton Unlimited
Starts at $12.99 / month
@@ -362,12 +357,12 @@
-
Proton plan
+
Proton Unlimited
Starts at $119.88 / year
diff --git a/app/templates/dashboard/setting.html b/app/templates/dashboard/setting.html index 7f5ef64..e595f11 100644 --- a/app/templates/dashboard/setting.html +++ b/app/templates/dashboard/setting.html @@ -79,7 +79,14 @@
{% endif %} - {% if partner_sub %}
Premium subscription managed by {{ partner_name }}.
{% endif %} + {% if partner_sub %} + {% if partner_sub.lifetime %} + +
Premium lifetime subscription managed by {{ partner_name }}.
+ {% else %} +
Premium subscription managed by {{ partner_name }}.
+ {% endif %} + {% endif %} {% elif current_user.in_trial() %} Your Premium trial expires {{ current_user.trial_end | dt }}. {% else %} diff --git a/app/tests/jobs/test_delete_mailbox_job.py b/app/tests/jobs/test_delete_mailbox_job.py index f4feaae..7975657 100644 --- a/app/tests/jobs/test_delete_mailbox_job.py +++ b/app/tests/jobs/test_delete_mailbox_job.py @@ -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 +@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 def test_delete_mailbox_transfer_mailbox_in_list(flask_client): user = create_new_user() diff --git a/app/tests/proton/test_account_linking.py b/app/tests/proton/test_account_linking.py new file mode 100644 index 0000000..d2511c0 --- /dev/null +++ b/app/tests/proton/test_account_linking.py @@ -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 diff --git a/app/tests/test_email_handler.py b/app/tests/test_email_handler.py index 5b33c71..1c9c650 100644 --- a/app/tests/test_email_handler.py +++ b/app/tests/test_email_handler.py @@ -14,7 +14,6 @@ from app.email_utils import generate_verp_email from app.mail_sender import mail_sender from app.models import ( Alias, - AuthorizedAddress, IgnoredEmail, EmailLog, Notification, @@ -24,35 +23,12 @@ from app.models import ( ) from app.utils import random_string, canonicalize_email from email_handler import ( - get_mailbox_from_mail_from, should_ignore, is_automatic_out_of_office, ) 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): assert should_ignore("mail_from", []) is False diff --git a/app/tests/test_email_utils.py b/app/tests/test_email_utils.py index c2cc8ab..cf498e1 100644 --- a/app/tests/test_email_utils.py +++ b/app/tests/test_email_utils.py @@ -791,12 +791,21 @@ def test_parse_id_from_bounce(): 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 ; 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( - "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 ; 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(): diff --git a/app/tests/test_mailbox_utils.py b/app/tests/test_mailbox_utils.py index d716b31..c80d522 100644 --- a/app/tests/test_mailbox_utils.py +++ b/app/tests/test_mailbox_utils.py @@ -6,9 +6,18 @@ import pytest from app import mailbox_utils, config from app.db import Session from app.mail_sender import mail_sender -from app.mailbox_utils import MailboxEmailChangeError -from app.models import Mailbox, MailboxActivation, User, Job, UserAuditLog +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 @@ -50,6 +59,14 @@ def test_already_used(): 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 def test_create_mailbox(): email = random_email() @@ -418,3 +435,75 @@ def test_perform_mailbox_email_change_success(): 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