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

This commit is contained in:
MrMeeb 2024-11-29 12:00:12 +00:00
parent 545eeda79b
commit 3c77f8af4b
17 changed files with 385 additions and 154 deletions

View File

@ -4,6 +4,7 @@ from enum import Enum
from typing import Optional from typing import Optional
import arrow 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 psycopg2.errors import UniqueViolation
@ -35,6 +36,7 @@ from app.utils import random_string
class SLPlanType(Enum): class SLPlanType(Enum):
Free = 1 Free = 1
Premium = 2 Premium = 2
PremiumLifetime = 3
@dataclass @dataclass
@ -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): 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(
@ -83,25 +86,30 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
PartnerSubscription.delete(sub.id) PartnerSubscription.delete(sub.id)
agent.record_custom_event("PlanChange", {"plan": "free"}) agent.record_custom_event("PlanChange", {"plan": "free"})
else: else:
end_time = plan.expiration
if plan.type == SLPlanType.PremiumLifetime:
end_time = None
if sub is None: if sub is None:
LOG.i( LOG.i(
f"Creating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]" f"Creating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
) )
create_partner_subscription( create_partner_subscription(
partner_user=partner_user, partner_user=partner_user,
expiration=plan.expiration, expiration=end_time,
lifetime=is_lifetime,
msg="Upgraded via partner. User did not have a previous partner subscription", msg="Upgraded via partner. User did not have a previous partner subscription",
) )
agent.record_custom_event("PlanChange", {"plan": "premium", "type": "new"}) agent.record_custom_event("PlanChange", {"plan": "premium", "type": "new"})
else: else:
if sub.end_at != plan.expiration: if sub.end_at != plan.expiration or sub.lifetime != is_lifetime:
LOG.i( LOG.i(
f"Updating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]" f"Updating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
) )
agent.record_custom_event( agent.record_custom_event(
"PlanChange", {"plan": "premium", "type": "extension"} "PlanChange", {"plan": "premium", "type": "extension"}
) )
sub.end_at = plan.expiration sub.end_at = plan.expiration if not is_lifetime else None
sub.lifetime = is_lifetime
emit_user_audit_log( emit_user_audit_log(
user=partner_user.user, user=partner_user.user,
action=UserAuditLogAction.SubscriptionExtended, action=UserAuditLogAction.SubscriptionExtended,
@ -185,7 +193,8 @@ class NewUserStrategy(ClientMergeStrategy):
user=new_user, user=new_user,
strategy=self.__class__.__name__, 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) return self.create_missing_link(canonical_email)
def create_missing_link(self, canonical_email: str): def create_missing_link(self, canonical_email: str):

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

@ -12,11 +12,13 @@ from app.email_utils import (
email_can_be_used_as_mailbox, email_can_be_used_as_mailbox,
send_email, send_email,
render, render,
get_email_domain_part,
) )
from app.email_validation import is_valid_email from app.email_validation import is_valid_email
from app.log import LOG from app.log import LOG
from app.models import User, Mailbox, Job, MailboxActivation from app.models import User, Mailbox, Job, MailboxActivation, Alias
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import canonicalize_email, sanitize_email
@dataclasses.dataclass @dataclasses.dataclass
@ -52,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"
@ -104,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)
@ -150,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,
@ -328,3 +335,56 @@ def perform_mailbox_email_change(mailbox_id: int) -> MailboxEmailChangeResult:
message="Invalid link", message="Invalid link",
message_category="error", message_category="error",
) )
def __get_alias_mailbox_from_email(
email_address: str, alias: Alias
) -> Optional[Mailbox]:
for mailbox in alias.mailboxes:
if mailbox.email == email_address:
return mailbox
for authorized_address in mailbox.authorized_addresses:
if authorized_address.email == email_address:
LOG.d(
"Found an authorized address for %s %s %s",
alias,
mailbox,
authorized_address,
)
return mailbox
return None
def __get_alias_mailbox_from_email_or_canonical_email(
email_address: str, alias: Alias
) -> Optional[Mailbox]:
# We need to first check for the uncanonicalized version because we still have users in the db with the
# email non canonicalized. So if it matches the already existing one use that, otherwise check the canonical one
mbox = __get_alias_mailbox_from_email(email_address, alias)
if mbox is not None:
return mbox
canonical_email = canonicalize_email(email_address)
if canonical_email != email_address:
return __get_alias_mailbox_from_email(canonical_email, alias)
return None
def get_mailbox_for_reply_phase(
envelope_mail_from: str, header_mail_from: str, alias
) -> Optional[Mailbox]:
"""return the corresponding mailbox given the mail_from and alias
Usually the mail_from=mailbox.email but it can also be one of the authorized address
"""
mbox = __get_alias_mailbox_from_email_or_canonical_email(envelope_mail_from, alias)
if mbox is not None:
return mbox
if not header_mail_from:
return None
envelope_from_domain = get_email_domain_part(envelope_mail_from)
header_from_domain = get_email_domain_part(header_mail_from)
if envelope_from_domain != header_from_domain:
return None
# For services that use VERP sending (envelope from has encoded data to account for bounces)
# if the domain is the same in the header from as the envelope from we can use the header from
return __get_alias_mailbox_from_email_or_canonical_email(header_mail_from, alias)

View File

@ -3778,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)
@ -3800,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

View File

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

View File

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

View File

@ -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,7 +173,7 @@ 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
@ -1008,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")
@ -1022,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,
) )
@ -1367,32 +1369,6 @@ def replace_original_message_id(alias: Alias, email_log: EmailLog, msg: Message)
msg[headers.REFERENCES] = " ".join(new_message_ids) msg[headers.REFERENCES] = " ".join(new_message_ids)
def get_mailbox_from_mail_from(mail_from: str, alias) -> Optional[Mailbox]:
"""return the corresponding mailbox given the mail_from and alias
Usually the mail_from=mailbox.email but it can also be one of the authorized address
"""
def __check(email_address: str, alias: Alias) -> Optional[Mailbox]:
for mailbox in alias.mailboxes:
if mailbox.email == email_address:
return mailbox
for authorized_address in mailbox.authorized_addresses:
if authorized_address.email == email_address:
LOG.d(
"Found an authorized address for %s %s %s",
alias,
mailbox,
authorized_address,
)
return mailbox
return None
# We need to first check for the uncanonicalized version because we still have users in the db with the
# email non canonicalized. So if it matches the already existing one use that, otherwise check the canonical one
return __check(mail_from, alias) or __check(canonicalize_email(mail_from), alias)
def handle_unknown_mailbox( def handle_unknown_mailbox(
envelope, msg, reply_email: str, user: User, alias: Alias, contact: Contact envelope, msg, reply_email: str, user: User, alias: Alias, contact: Contact
): ):

View File

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

View File

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

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

@ -57,31 +57,21 @@
{% 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>
<div class="alert alert-info">
<span class="badge badge-success">new</span> SimpleLogin Premium now includes Proton Pass premium features.
<a href="https://simplelogin.io/blog/sl-premium-including-pass-plus/"
target="_blank">Learn more ↗</a>
</div>
{% if manual_sub %} {% if manual_sub %}
<div class="alert alert-info mt-0 mb-6"> <div class="alert alert-info mt-0 mb-6">
@ -131,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"
@ -223,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>
@ -362,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>

View File

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

View File

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

View File

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

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

View File

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

View File

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