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
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:
parent
545eeda79b
commit
3c77f8af4b
@ -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):
|
||||
|
@ -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 <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)
|
||||
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:
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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}")
|
||||
|
||||
|
@ -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
|
||||
):
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
35
app/migrations/versions/2024_112619_085f77996ce3_.py
Normal file
35
app/migrations/versions/2024_112619_085f77996ce3_.py
Normal 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 ###
|
@ -57,31 +57,21 @@
|
||||
{% endblock %}
|
||||
{% block default_content %}
|
||||
|
||||
{% if NOW.timestamp < 1701475201 %}
|
||||
{% if NOW.timestamp < 1733184000 %}
|
||||
|
||||
<div class="alert alert-info">
|
||||
Black Friday Deal: 33% off on the yearly plan for the <b>first</b> year ($20 instead of $30).
|
||||
<div class="alert alert-primary">
|
||||
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>
|
||||
Please use this coupon code
|
||||
<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.
|
||||
Available until December 3, 2024.
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
</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 %}
|
||||
|
||||
<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"
|
||||
style="font-size: 12px">Save $18</span></a>
|
||||
</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">
|
||||
<!-- monthly tab content -->
|
||||
<div class="tab-pane"
|
||||
@ -223,12 +218,12 @@
|
||||
<div class="card card-md flex-grow-1">
|
||||
<div class="card-body">
|
||||
<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="text-center mt-4 mb-6">
|
||||
<a class="btn btn-lg btn-outline-primary w-100"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@ -362,12 +357,12 @@
|
||||
<div class="card card-md flex-grow-1">
|
||||
<div class="card-body">
|
||||
<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="text-center mt-4 mb-6">
|
||||
<a class="btn btn-lg btn-outline-primary w-100"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -79,7 +79,14 @@
|
||||
</a>
|
||||
</div>
|
||||
{% 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() %}
|
||||
Your Premium trial expires {{ current_user.trial_end | dt }}.
|
||||
{% else %}
|
||||
|
@ -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()
|
||||
|
100
app/tests/proton/test_account_linking.py
Normal file
100
app/tests/proton/test_account_linking.py
Normal 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
|
@ -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
|
||||
|
||||
|
@ -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 <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(
|
||||
"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():
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user