Compare commits
5 Commits
Author | SHA1 | Date | |
---|---|---|---|
ce5124605a | |||
2c82b03f8d | |||
1b7a6223ac | |||
75331c62a4 | |||
3f68a3e640 |
@ -34,7 +34,7 @@ poetry install
|
||||
On Mac, sometimes you might need to install some other packages via `brew`:
|
||||
|
||||
```bash
|
||||
brew install pkg-config libffi openssl postgresql
|
||||
brew install pkg-config libffi openssl postgresql@13
|
||||
```
|
||||
|
||||
You also need to install `gpg` tool, on Mac it can be done with:
|
||||
|
@ -207,13 +207,14 @@ def process_login_case(
|
||||
) -> LinkResult:
|
||||
# Sanitize email just in case
|
||||
link_request.email = sanitize_email(link_request.email)
|
||||
check_alias(link_request.email)
|
||||
# Try to find a SimpleLogin user registered with that partner user id
|
||||
partner_user = PartnerUser.get_by(
|
||||
partner_id=partner.id, external_user_id=link_request.external_user_id
|
||||
)
|
||||
if partner_user is None:
|
||||
# We didn't find any SimpleLogin user registered with that partner user id
|
||||
# Make sure they aren't using an alias as their link email
|
||||
check_alias(link_request.email)
|
||||
# Try to find it using the partner's e-mail address
|
||||
user = User.get_by(email=link_request.email)
|
||||
return get_login_strategy(link_request, user, partner).process()
|
||||
|
@ -6,8 +6,7 @@ from typing import Optional
|
||||
import itsdangerous
|
||||
from app import config
|
||||
from app.log import LOG
|
||||
from app.models import User
|
||||
|
||||
from app.models import User, AliasOptions
|
||||
|
||||
signer = itsdangerous.TimestampSigner(config.CUSTOM_ALIAS_SECRET)
|
||||
|
||||
@ -43,7 +42,9 @@ def check_suffix_signature(signed_suffix: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
|
||||
def verify_prefix_suffix(
|
||||
user: User, alias_prefix, alias_suffix, alias_options: Optional[AliasOptions] = None
|
||||
) -> bool:
|
||||
"""verify if user could create an alias with the given prefix and suffix"""
|
||||
if not alias_prefix or not alias_suffix: # should be caught on frontend
|
||||
return False
|
||||
@ -56,7 +57,7 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
|
||||
alias_domain_prefix, alias_domain = alias_suffix.split("@", 1)
|
||||
|
||||
# alias_domain must be either one of user custom domains or built-in domains
|
||||
if alias_domain not in user.available_alias_domains():
|
||||
if alias_domain not in user.available_alias_domains(alias_options=alias_options):
|
||||
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
return False
|
||||
|
||||
@ -64,7 +65,7 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
|
||||
# 1) alias_suffix must start with "." and
|
||||
# 2) alias_domain_prefix must come from the word list
|
||||
if (
|
||||
alias_domain in user.available_sl_domains()
|
||||
alias_domain in user.available_sl_domains(alias_options=alias_options)
|
||||
and alias_domain not in user_custom_domains
|
||||
# when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty
|
||||
and not config.DISABLE_ALIAS_SUFFIX
|
||||
@ -80,14 +81,18 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
|
||||
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
return False
|
||||
|
||||
if alias_domain not in user.available_sl_domains():
|
||||
if alias_domain not in user.available_sl_domains(
|
||||
alias_options=alias_options
|
||||
):
|
||||
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_alias_suffixes(user: User) -> [AliasSuffix]:
|
||||
def get_alias_suffixes(
|
||||
user: User, alias_options: Optional[AliasOptions] = None
|
||||
) -> [AliasSuffix]:
|
||||
"""
|
||||
Similar to as get_available_suffixes() but also return custom domain that doesn't have MX set up.
|
||||
"""
|
||||
@ -99,7 +104,12 @@ def get_alias_suffixes(user: User) -> [AliasSuffix]:
|
||||
# for each user domain, generate both the domain and a random suffix version
|
||||
for custom_domain in user_custom_domains:
|
||||
if custom_domain.random_prefix_generation:
|
||||
suffix = "." + user.get_random_alias_suffix() + "@" + custom_domain.domain
|
||||
suffix = (
|
||||
"."
|
||||
+ user.get_random_alias_suffix(custom_domain)
|
||||
+ "@"
|
||||
+ custom_domain.domain
|
||||
)
|
||||
alias_suffix = AliasSuffix(
|
||||
is_custom=True,
|
||||
suffix=suffix,
|
||||
@ -134,7 +144,7 @@ def get_alias_suffixes(user: User) -> [AliasSuffix]:
|
||||
alias_suffixes.append(alias_suffix)
|
||||
|
||||
# then SimpleLogin domain
|
||||
for sl_domain in user.get_sl_domains():
|
||||
for sl_domain in user.get_sl_domains(alias_options=alias_options):
|
||||
suffix = (
|
||||
(
|
||||
""
|
||||
|
@ -357,7 +357,7 @@ def auth_payload(user, device) -> dict:
|
||||
|
||||
|
||||
@api_bp.route("/auth/forgot_password", methods=["POST"])
|
||||
@limiter.limit("10/minute")
|
||||
@limiter.limit("2/minute")
|
||||
def forgot_password():
|
||||
"""
|
||||
User forgot password
|
||||
|
@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import dataclasses
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
|
||||
@ -7,6 +8,7 @@ from flask import jsonify, g, request, make_response
|
||||
from app import s3, config
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.config import SESSION_COOKIE_NAME
|
||||
from app.dashboard.views.index import get_stats
|
||||
from app.db import Session
|
||||
from app.models import ApiKey, File, PartnerUser, User
|
||||
from app.proton.utils import get_proton_partner
|
||||
@ -136,3 +138,22 @@ def logout():
|
||||
response.delete_cookie(SESSION_COOKIE_NAME)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@api_bp.route("/stats")
|
||||
@require_api_auth
|
||||
def user_stats():
|
||||
"""
|
||||
Return stats
|
||||
|
||||
Output as json
|
||||
- nb_alias
|
||||
- nb_forward
|
||||
- nb_reply
|
||||
- nb_block
|
||||
|
||||
"""
|
||||
user = g.user
|
||||
stats = get_stats(user)
|
||||
|
||||
return jsonify(dataclasses.asdict(stats))
|
||||
|
@ -90,7 +90,7 @@ def create_contact(user: User, alias: Alias, contact_address: str) -> Contact:
|
||||
alias_id=alias.id,
|
||||
website_email=contact_email,
|
||||
name=contact_name,
|
||||
reply_email=generate_reply_email(contact_email, user),
|
||||
reply_email=generate_reply_email(contact_email, alias),
|
||||
)
|
||||
|
||||
LOG.d(
|
||||
|
@ -68,9 +68,14 @@ def coupon_route():
|
||||
)
|
||||
return redirect(request.url)
|
||||
|
||||
coupon.used_by_user_id = current_user.id
|
||||
coupon.used = True
|
||||
Session.commit()
|
||||
updated = (
|
||||
Session.query(Coupon)
|
||||
.filter_by(code=code, used=False)
|
||||
.update({"used_by_user_id": current_user.id, "used": True})
|
||||
)
|
||||
if updated != 1:
|
||||
flash("Coupon is not valid", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
manual_sub: ManualSubscription = ManualSubscription.get_by(
|
||||
user_id=current_user.id
|
||||
|
@ -54,6 +54,7 @@ from app.models import (
|
||||
IgnoreBounceSender,
|
||||
InvalidMailboxDomain,
|
||||
VerpType,
|
||||
available_sl_email,
|
||||
)
|
||||
from app.utils import (
|
||||
random_string,
|
||||
@ -1043,7 +1044,7 @@ def replace(msg: Union[Message, str], old, new) -> Union[Message, str]:
|
||||
return msg
|
||||
|
||||
|
||||
def generate_reply_email(contact_email: str, user: User) -> str:
|
||||
def generate_reply_email(contact_email: str, alias: Alias) -> str:
|
||||
"""
|
||||
generate a reply_email (aka reverse-alias), make sure it isn't used by any contact
|
||||
"""
|
||||
@ -1054,6 +1055,7 @@ def generate_reply_email(contact_email: str, user: User) -> str:
|
||||
|
||||
include_sender_in_reverse_alias = False
|
||||
|
||||
user = alias.user
|
||||
# user has set this option explicitly
|
||||
if user.include_sender_in_reverse_alias is not None:
|
||||
include_sender_in_reverse_alias = user.include_sender_in_reverse_alias
|
||||
@ -1068,6 +1070,12 @@ def generate_reply_email(contact_email: str, user: User) -> str:
|
||||
contact_email = contact_email.replace(".", "_")
|
||||
contact_email = convert_to_alphanumeric(contact_email)
|
||||
|
||||
reply_domain = config.EMAIL_DOMAIN
|
||||
alias_domain = get_email_domain_part(alias.email)
|
||||
sl_domain = SLDomain.get_by(domain=alias_domain)
|
||||
if sl_domain and sl_domain.use_as_reverse_alias:
|
||||
reply_domain = alias_domain
|
||||
|
||||
# not use while to avoid infinite loop
|
||||
for _ in range(1000):
|
||||
if include_sender_in_reverse_alias and contact_email:
|
||||
@ -1075,15 +1083,15 @@ def generate_reply_email(contact_email: str, user: User) -> str:
|
||||
reply_email = (
|
||||
# do not use the ra+ anymore
|
||||
# f"ra+{contact_email}+{random_string(random_length)}@{config.EMAIL_DOMAIN}"
|
||||
f"{contact_email}_{random_string(random_length)}@{config.EMAIL_DOMAIN}"
|
||||
f"{contact_email}_{random_string(random_length)}@{reply_domain}"
|
||||
)
|
||||
else:
|
||||
random_length = random.randint(20, 50)
|
||||
# do not use the ra+ anymore
|
||||
# reply_email = f"ra+{random_string(random_length)}@{config.EMAIL_DOMAIN}"
|
||||
reply_email = f"{random_string(random_length)}@{config.EMAIL_DOMAIN}"
|
||||
reply_email = f"{random_string(random_length)}@{reply_domain}"
|
||||
|
||||
if not Contact.get_by(reply_email=reply_email):
|
||||
if available_sl_email(reply_email):
|
||||
return reply_email
|
||||
|
||||
raise Exception("Cannot generate reply email")
|
||||
|
@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import dataclasses
|
||||
import enum
|
||||
import hashlib
|
||||
import hmac
|
||||
@ -18,7 +19,7 @@ from flanker.addresslib import address
|
||||
from flask import url_for
|
||||
from flask_login import UserMixin
|
||||
from jinja2 import FileSystemLoader, Environment
|
||||
from sqlalchemy import orm
|
||||
from sqlalchemy import orm, or_
|
||||
from sqlalchemy import text, desc, CheckConstraint, Index, Column
|
||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
@ -273,6 +274,12 @@ class IntEnumType(sa.types.TypeDecorator):
|
||||
return self._enum_type(enum_value)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AliasOptions:
|
||||
show_sl_domains: bool = True
|
||||
show_partner_domains: Optional[Partner] = None
|
||||
|
||||
|
||||
class Hibp(Base, ModelMixin):
|
||||
__tablename__ = "hibp"
|
||||
name = sa.Column(sa.String(), nullable=False, unique=True, index=True)
|
||||
@ -291,7 +298,9 @@ class HibpNotifiedAlias(Base, ModelMixin):
|
||||
"""
|
||||
|
||||
__tablename__ = "hibp_notified_alias"
|
||||
alias_id = sa.Column(sa.ForeignKey("alias.id", ondelete="cascade"), nullable=False)
|
||||
alias_id = sa.Column(
|
||||
sa.ForeignKey("alias.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=False)
|
||||
|
||||
notified_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False)
|
||||
@ -419,7 +428,10 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
|
||||
# newsletter is sent to this address
|
||||
newsletter_alias_id = sa.Column(
|
||||
sa.ForeignKey("alias.id", ondelete="SET NULL"), nullable=True, default=None
|
||||
sa.ForeignKey("alias.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# whether to include the sender address in reverse-alias
|
||||
@ -557,7 +569,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
|
||||
@classmethod
|
||||
def create(cls, email, name="", password=None, from_partner=False, **kwargs):
|
||||
user: User = super(User, cls).create(email=email, name=name, **kwargs)
|
||||
user: User = super(User, cls).create(email=email, name=name[:100], **kwargs)
|
||||
|
||||
if password:
|
||||
user.set_password(password)
|
||||
@ -867,14 +879,16 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
def custom_domains(self):
|
||||
return CustomDomain.filter_by(user_id=self.id, verified=True).all()
|
||||
|
||||
def available_domains_for_random_alias(self) -> List[Tuple[bool, str]]:
|
||||
def available_domains_for_random_alias(
|
||||
self, alias_options: Optional[AliasOptions] = None
|
||||
) -> List[Tuple[bool, str]]:
|
||||
"""Return available domains for user to create random aliases
|
||||
Each result record contains:
|
||||
- whether the domain belongs to SimpleLogin
|
||||
- the domain
|
||||
"""
|
||||
res = []
|
||||
for domain in self.available_sl_domains():
|
||||
for domain in self.available_sl_domains(alias_options=alias_options):
|
||||
res.append((True, domain))
|
||||
|
||||
for custom_domain in self.verified_custom_domains():
|
||||
@ -959,30 +973,55 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
|
||||
return None, "", False
|
||||
|
||||
def available_sl_domains(self) -> [str]:
|
||||
def available_sl_domains(
|
||||
self, alias_options: Optional[AliasOptions] = None
|
||||
) -> [str]:
|
||||
"""
|
||||
Return all SimpleLogin domains that user can use when creating a new alias, including:
|
||||
- SimpleLogin public domains, available for all users (ALIAS_DOMAIN)
|
||||
- SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN)
|
||||
"""
|
||||
return [sl_domain.domain for sl_domain in self.get_sl_domains()]
|
||||
return [
|
||||
sl_domain.domain
|
||||
for sl_domain in self.get_sl_domains(alias_options=alias_options)
|
||||
]
|
||||
|
||||
def get_sl_domains(self) -> List["SLDomain"]:
|
||||
query = SLDomain.filter_by(hidden=False).order_by(SLDomain.order)
|
||||
|
||||
if self.is_premium():
|
||||
return query.all()
|
||||
def get_sl_domains(
|
||||
self, alias_options: Optional[AliasOptions] = None
|
||||
) -> list["SLDomain"]:
|
||||
if alias_options is None:
|
||||
alias_options = AliasOptions()
|
||||
conditions = [SLDomain.hidden == False] # noqa: E712
|
||||
if not self.is_premium():
|
||||
conditions.append(SLDomain.premium_only == False) # noqa: E712
|
||||
partner_domain_cond = [] # noqa:E711
|
||||
if alias_options.show_partner_domains is not None:
|
||||
partner_user = PartnerUser.filter_by(
|
||||
user_id=self.id, partner_id=alias_options.show_partner_domains.id
|
||||
).first()
|
||||
if partner_user is not None:
|
||||
partner_domain_cond.append(
|
||||
SLDomain.partner_id == partner_user.partner_id
|
||||
)
|
||||
if alias_options.show_sl_domains:
|
||||
partner_domain_cond.append(SLDomain.partner_id == None) # noqa:E711
|
||||
if len(partner_domain_cond) == 1:
|
||||
conditions.append(partner_domain_cond[0])
|
||||
else:
|
||||
return query.filter_by(premium_only=False).all()
|
||||
conditions.append(or_(*partner_domain_cond))
|
||||
query = Session.query(SLDomain).filter(*conditions).order_by(SLDomain.order)
|
||||
return query.all()
|
||||
|
||||
def available_alias_domains(self) -> [str]:
|
||||
def available_alias_domains(
|
||||
self, alias_options: Optional[AliasOptions] = None
|
||||
) -> [str]:
|
||||
"""return all domains that user can use when creating a new alias, including:
|
||||
- SimpleLogin public domains, available for all users (ALIAS_DOMAIN)
|
||||
- SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN)
|
||||
- Verified custom domains
|
||||
|
||||
"""
|
||||
domains = self.available_sl_domains()
|
||||
domains = self.available_sl_domains(alias_options=alias_options)
|
||||
|
||||
for custom_domain in self.verified_custom_domains():
|
||||
domains.append(custom_domain.domain)
|
||||
@ -1000,17 +1039,22 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
> 0
|
||||
)
|
||||
|
||||
def get_random_alias_suffix(self):
|
||||
def get_random_alias_suffix(self, custom_domain: Optional["CustomDomain"] = None):
|
||||
"""Get random suffix for an alias based on user's preference.
|
||||
|
||||
Use a shorter suffix in case of custom domain
|
||||
|
||||
Returns:
|
||||
str: the random suffix generated
|
||||
"""
|
||||
if self.random_alias_suffix == AliasSuffixEnum.random_string.value:
|
||||
return random_string(config.ALIAS_RANDOM_SUFFIX_LENGTH, include_digits=True)
|
||||
|
||||
if custom_domain is None:
|
||||
return random_words(1, 3)
|
||||
|
||||
return random_words(1)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.id} {self.name} {self.email}>"
|
||||
|
||||
@ -1254,16 +1298,30 @@ class OauthToken(Base, ModelMixin):
|
||||
return self.expired < arrow.now()
|
||||
|
||||
|
||||
def generate_email(
|
||||
def available_sl_email(email: str) -> bool:
|
||||
if (
|
||||
Alias.get_by(email=email)
|
||||
or Contact.get_by(reply_email=email)
|
||||
or DeletedAlias.get_by(email=email)
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def generate_random_alias_email(
|
||||
scheme: int = AliasGeneratorEnum.word.value,
|
||||
in_hex: bool = False,
|
||||
alias_domain=config.FIRST_ALIAS_DOMAIN,
|
||||
alias_domain: str = config.FIRST_ALIAS_DOMAIN,
|
||||
retries: int = 10,
|
||||
) -> str:
|
||||
"""generate an email address that does not exist before
|
||||
:param alias_domain: the domain used to generate the alias.
|
||||
:param scheme: int, value of AliasGeneratorEnum, indicate how the email is generated
|
||||
:param retries: int, How many times we can try to generate an alias in case of collision
|
||||
:type in_hex: bool, if the generate scheme is uuid, is hex favorable?
|
||||
"""
|
||||
if retries <= 0:
|
||||
raise Exception("Cannot generate alias after many retries")
|
||||
if scheme == AliasGeneratorEnum.uuid.value:
|
||||
name = uuid.uuid4().hex if in_hex else uuid.uuid4().__str__()
|
||||
random_email = name + "@" + alias_domain
|
||||
@ -1273,15 +1331,15 @@ def generate_email(
|
||||
random_email = random_email.lower().strip()
|
||||
|
||||
# check that the client does not exist yet
|
||||
if not Alias.get_by(email=random_email) and not DeletedAlias.get_by(
|
||||
email=random_email
|
||||
):
|
||||
if available_sl_email(random_email):
|
||||
LOG.d("generate email %s", random_email)
|
||||
return random_email
|
||||
|
||||
# Rerun the function
|
||||
LOG.w("email %s already exists, generate a new email", random_email)
|
||||
return generate_email(scheme=scheme, in_hex=in_hex)
|
||||
return generate_random_alias_email(
|
||||
scheme=scheme, in_hex=in_hex, retries=retries - 1
|
||||
)
|
||||
|
||||
|
||||
class Alias(Base, ModelMixin):
|
||||
@ -1480,7 +1538,7 @@ class Alias(Base, ModelMixin):
|
||||
suffix = user.get_random_alias_suffix()
|
||||
email = f"{prefix}.{suffix}@{config.FIRST_ALIAS_DOMAIN}"
|
||||
|
||||
if not cls.get_by(email=email) and not DeletedAlias.get_by(email=email):
|
||||
if available_sl_email(email):
|
||||
break
|
||||
|
||||
return Alias.create(
|
||||
@ -1509,7 +1567,7 @@ class Alias(Base, ModelMixin):
|
||||
|
||||
if user.default_alias_custom_domain_id:
|
||||
custom_domain = CustomDomain.get(user.default_alias_custom_domain_id)
|
||||
random_email = generate_email(
|
||||
random_email = generate_random_alias_email(
|
||||
scheme=scheme, in_hex=in_hex, alias_domain=custom_domain.domain
|
||||
)
|
||||
elif user.default_alias_public_domain_id:
|
||||
@ -1517,12 +1575,12 @@ class Alias(Base, ModelMixin):
|
||||
if sl_domain.premium_only and not user.is_premium():
|
||||
LOG.w("%s not premium, cannot use %s", user, sl_domain)
|
||||
else:
|
||||
random_email = generate_email(
|
||||
random_email = generate_random_alias_email(
|
||||
scheme=scheme, in_hex=in_hex, alias_domain=sl_domain.domain
|
||||
)
|
||||
|
||||
if not random_email:
|
||||
random_email = generate_email(scheme=scheme, in_hex=in_hex)
|
||||
random_email = generate_random_alias_email(scheme=scheme, in_hex=in_hex)
|
||||
|
||||
alias = Alias.create(
|
||||
user_id=user.id,
|
||||
@ -1556,7 +1614,9 @@ class ClientUser(Base, ModelMixin):
|
||||
client_id = sa.Column(sa.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
|
||||
|
||||
# Null means client has access to user original email
|
||||
alias_id = sa.Column(sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=True)
|
||||
alias_id = sa.Column(
|
||||
sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=True, index=True
|
||||
)
|
||||
|
||||
# user can decide to send to client another name
|
||||
name = sa.Column(
|
||||
@ -1675,7 +1735,7 @@ class Contact(Base, ModelMixin):
|
||||
is_cc = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
pgp_public_key = sa.Column(sa.Text, nullable=True)
|
||||
pgp_finger_print = sa.Column(sa.String(512), nullable=True)
|
||||
pgp_finger_print = sa.Column(sa.String(512), nullable=True, index=True)
|
||||
|
||||
alias = orm.relationship(Alias, backref="contacts")
|
||||
user = orm.relationship(User)
|
||||
@ -2086,7 +2146,9 @@ class AliasUsedOn(Base, ModelMixin):
|
||||
sa.UniqueConstraint("alias_id", "hostname", name="uq_alias_used"),
|
||||
)
|
||||
|
||||
alias_id = sa.Column(sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=False)
|
||||
alias_id = sa.Column(
|
||||
sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||
|
||||
alias = orm.relationship(Alias)
|
||||
@ -2763,6 +2825,31 @@ class Notification(Base, ModelMixin):
|
||||
)
|
||||
|
||||
|
||||
class Partner(Base, ModelMixin):
|
||||
__tablename__ = "partner"
|
||||
|
||||
name = sa.Column(sa.String(128), unique=True, nullable=False)
|
||||
contact_email = sa.Column(sa.String(128), unique=True, nullable=False)
|
||||
|
||||
@staticmethod
|
||||
def find_by_token(token: str) -> Optional[Partner]:
|
||||
hmaced = PartnerApiToken.hmac_token(token)
|
||||
res = (
|
||||
Session.query(Partner, PartnerApiToken)
|
||||
.filter(
|
||||
and_(
|
||||
PartnerApiToken.token == hmaced,
|
||||
Partner.id == PartnerApiToken.partner_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if res:
|
||||
partner, partner_api_token = res
|
||||
return partner
|
||||
return None
|
||||
|
||||
|
||||
class SLDomain(Base, ModelMixin):
|
||||
"""SimpleLogin domains"""
|
||||
|
||||
@ -2780,12 +2867,23 @@ class SLDomain(Base, ModelMixin):
|
||||
sa.Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
|
||||
partner_id = sa.Column(
|
||||
sa.ForeignKey(Partner.id, ondelete="cascade"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
server_default="NULL",
|
||||
)
|
||||
|
||||
# if enabled, do not show this domain when user creates a custom alias
|
||||
hidden = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
# the order in which the domains are shown when user creates a custom alias
|
||||
order = sa.Column(sa.Integer, nullable=False, default=0, server_default="0")
|
||||
|
||||
use_as_reverse_alias = sa.Column(
|
||||
sa.Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SLDomain {self.domain} {'Premium' if self.premium_only else 'Free'}"
|
||||
|
||||
@ -3226,31 +3324,6 @@ class ProviderComplaint(Base, ModelMixin):
|
||||
refused_email = orm.relationship(RefusedEmail, foreign_keys=[refused_email_id])
|
||||
|
||||
|
||||
class Partner(Base, ModelMixin):
|
||||
__tablename__ = "partner"
|
||||
|
||||
name = sa.Column(sa.String(128), unique=True, nullable=False)
|
||||
contact_email = sa.Column(sa.String(128), unique=True, nullable=False)
|
||||
|
||||
@staticmethod
|
||||
def find_by_token(token: str) -> Optional[Partner]:
|
||||
hmaced = PartnerApiToken.hmac_token(token)
|
||||
res = (
|
||||
Session.query(Partner, PartnerApiToken)
|
||||
.filter(
|
||||
and_(
|
||||
PartnerApiToken.token == hmaced,
|
||||
Partner.id == PartnerApiToken.partner_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if res:
|
||||
partner, partner_api_token = res
|
||||
return partner
|
||||
return None
|
||||
|
||||
|
||||
class PartnerApiToken(Base, ModelMixin):
|
||||
__tablename__ = "partner_api_token"
|
||||
|
||||
@ -3320,7 +3393,7 @@ class PartnerSubscription(Base, ModelMixin):
|
||||
)
|
||||
|
||||
# when the partner subscription ends
|
||||
end_at = sa.Column(ArrowType, nullable=False)
|
||||
end_at = sa.Column(ArrowType, nullable=False, index=True)
|
||||
|
||||
partner_user = orm.relationship(PartnerUser)
|
||||
|
||||
|
@ -32,8 +32,8 @@ def random_words(words: int = 2, numbers: int = 0):
|
||||
fields = [secrets.choice(_words) for i in range(words)]
|
||||
|
||||
if numbers > 0:
|
||||
fields.append("".join([str(random.randint(0, 9)) for i in range(numbers)]))
|
||||
return "".join(fields)
|
||||
digits = "".join([str(random.randint(0, 9)) for i in range(numbers)])
|
||||
return "_".join(fields) + digits
|
||||
else:
|
||||
return "_".join(fields)
|
||||
|
||||
|
@ -15,6 +15,7 @@
|
||||
- [GET /api/user/cookie_token](#get-apiusercookie_token): Get a one time use token to exchange it for a valid cookie
|
||||
- [PATCH /api/user_info](#patch-apiuser_info): Update user's information.
|
||||
- [POST /api/api_key](#post-apiapi_key): Create a new API key.
|
||||
- [GET /api/stats](#get-apistats): Get user's stats.
|
||||
- [GET /api/logout](#get-apilogout): Log out.
|
||||
|
||||
[Alias endpoints](#alias-endpoints)
|
||||
@ -226,6 +227,22 @@ Input:
|
||||
|
||||
Output: same as GET /api/user_info
|
||||
|
||||
#### GET /api/stats
|
||||
|
||||
Given the API Key, return stats about the number of aliases, number of emails forwarded/replied/blocked
|
||||
|
||||
Input:
|
||||
|
||||
- `Authentication` header that contains the api key
|
||||
|
||||
Output: if api key is correct, return a json with the following fields:
|
||||
|
||||
```json
|
||||
{"nb_alias": 1, "nb_block": 0, "nb_forward": 0, "nb_reply": 0}
|
||||
```
|
||||
|
||||
If api key is incorrect, return 401.
|
||||
|
||||
#### PATCH /api/sudo
|
||||
|
||||
Enable sudo mode
|
||||
@ -694,7 +711,7 @@ Return 200 and `existed=true` if contact is already added.
|
||||
|
||||
It can return 403 with an error if the user cannot create reverse alias.
|
||||
|
||||
``json
|
||||
```json
|
||||
{
|
||||
"error": "Please upgrade to create a reverse-alias"
|
||||
}
|
||||
|
@ -161,6 +161,7 @@ from app.models import (
|
||||
MessageIDMatching,
|
||||
Notification,
|
||||
VerpType,
|
||||
SLDomain,
|
||||
)
|
||||
from app.pgp_utils import (
|
||||
PGPException,
|
||||
@ -243,7 +244,7 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
|
||||
website_email=contact_email,
|
||||
name=contact_name,
|
||||
mail_from=mail_from,
|
||||
reply_email=generate_reply_email(contact_email, alias.user)
|
||||
reply_email=generate_reply_email(contact_email, alias)
|
||||
if is_valid_email(contact_email)
|
||||
else NOREPLY,
|
||||
automatic_created=True,
|
||||
@ -304,7 +305,7 @@ def get_or_create_reply_to_contact(
|
||||
alias_id=alias.id,
|
||||
website_email=contact_address,
|
||||
name=contact_name,
|
||||
reply_email=generate_reply_email(contact_address, alias.user),
|
||||
reply_email=generate_reply_email(contact_address, alias),
|
||||
automatic_created=True,
|
||||
)
|
||||
Session.commit()
|
||||
@ -372,7 +373,7 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
|
||||
alias_id=alias.id,
|
||||
website_email=contact_email,
|
||||
name=full_address.display_name,
|
||||
reply_email=generate_reply_email(contact_email, alias.user),
|
||||
reply_email=generate_reply_email(contact_email, alias),
|
||||
is_cc=header.lower() == "cc",
|
||||
automatic_created=True,
|
||||
)
|
||||
@ -945,10 +946,11 @@ def forward_email_to_mailbox(
|
||||
envelope.rcpt_options,
|
||||
)
|
||||
|
||||
contact_domain = get_email_domain_part(contact.reply_email)
|
||||
try:
|
||||
sl_sendmail(
|
||||
# use a different envelope sender for each forward (aka VERP)
|
||||
generate_verp_email(VerpType.bounce_forward, email_log.id),
|
||||
generate_verp_email(VerpType.bounce_forward, email_log.id, contact_domain),
|
||||
mailbox.email,
|
||||
msg,
|
||||
envelope.mail_options,
|
||||
@ -1017,8 +1019,12 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
|
||||
|
||||
reply_email = rcpt_to
|
||||
|
||||
# reply_email must end with EMAIL_DOMAIN
|
||||
reply_domain = get_email_domain_part(reply_email)
|
||||
|
||||
# reply_email must end with EMAIL_DOMAIN or a domain that can be used as reverse alias domain
|
||||
if not reply_email.endswith(EMAIL_DOMAIN):
|
||||
sl_domain: SLDomain = SLDomain.get_by(domain=reply_domain)
|
||||
if sl_domain is None:
|
||||
LOG.w(f"Reply email {reply_email} has wrong domain")
|
||||
return False, status.E501
|
||||
|
||||
@ -1032,7 +1038,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
|
||||
|
||||
alias = contact.alias
|
||||
alias_address: str = contact.alias.email
|
||||
alias_domain = alias_address[alias_address.find("@") + 1 :]
|
||||
alias_domain = get_email_domain_part(alias_address)
|
||||
|
||||
# Sanity check: verify alias domain is managed by SimpleLogin
|
||||
# scenario: a user have removed a domain but due to a bug, the aliases are still there
|
||||
|
@ -42,14 +42,16 @@ def add_sl_domains():
|
||||
LOG.d("%s is already a SL domain", alias_domain)
|
||||
else:
|
||||
LOG.i("Add %s to SL domain", alias_domain)
|
||||
SLDomain.create(domain=alias_domain)
|
||||
SLDomain.create(domain=alias_domain, use_as_reverse_alias=True)
|
||||
|
||||
for premium_domain in PREMIUM_ALIAS_DOMAINS:
|
||||
if SLDomain.get_by(domain=premium_domain):
|
||||
LOG.d("%s is already a SL domain", premium_domain)
|
||||
else:
|
||||
LOG.i("Add %s to SL domain", premium_domain)
|
||||
SLDomain.create(domain=premium_domain, premium_only=True)
|
||||
SLDomain.create(
|
||||
domain=premium_domain, premium_only=True, use_as_reverse_alias=True
|
||||
)
|
||||
|
||||
Session.commit()
|
||||
|
||||
|
31
app/migrations/versions/2023_040318_5f4a5625da66_.py
Normal file
31
app/migrations/versions/2023_040318_5f4a5625da66_.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 5f4a5625da66
|
||||
Revises: 2c2093c82bc0
|
||||
Create Date: 2023-04-03 18:30:46.488231
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '5f4a5625da66'
|
||||
down_revision = '2c2093c82bc0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('public_domain', sa.Column('partner_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(None, 'public_domain', 'partner', ['partner_id'], ['id'], ondelete='cascade')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'public_domain', type_='foreignkey')
|
||||
op.drop_column('public_domain', 'partner_id')
|
||||
# ### end Alembic commands ###
|
29
app/migrations/versions/2023_041418_893c0d18475f_.py
Normal file
29
app/migrations/versions/2023_041418_893c0d18475f_.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 893c0d18475f
|
||||
Revises: 5f4a5625da66
|
||||
Create Date: 2023-04-14 18:20:03.807367
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '893c0d18475f'
|
||||
down_revision = '5f4a5625da66'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_index(op.f('ix_contact_pgp_finger_print'), 'contact', ['pgp_finger_print'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_contact_pgp_finger_print'), table_name='contact')
|
||||
# ### end Alembic commands ###
|
35
app/migrations/versions/2023_041419_bc496c0a0279_.py
Normal file
35
app/migrations/versions/2023_041419_bc496c0a0279_.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: bc496c0a0279
|
||||
Revises: 893c0d18475f
|
||||
Create Date: 2023-04-14 19:09:38.540514
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'bc496c0a0279'
|
||||
down_revision = '893c0d18475f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_index(op.f('ix_alias_used_on_alias_id'), 'alias_used_on', ['alias_id'], unique=False)
|
||||
op.create_index(op.f('ix_client_user_alias_id'), 'client_user', ['alias_id'], unique=False)
|
||||
op.create_index(op.f('ix_hibp_notified_alias_alias_id'), 'hibp_notified_alias', ['alias_id'], unique=False)
|
||||
op.create_index(op.f('ix_users_newsletter_alias_id'), 'users', ['newsletter_alias_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_users_newsletter_alias_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_hibp_notified_alias_alias_id'), table_name='hibp_notified_alias')
|
||||
op.drop_index(op.f('ix_client_user_alias_id'), table_name='client_user')
|
||||
op.drop_index(op.f('ix_alias_used_on_alias_id'), table_name='alias_used_on')
|
||||
# ### end Alembic commands ###
|
29
app/migrations/versions/2023_041520_2d89315ac650_.py
Normal file
29
app/migrations/versions/2023_041520_2d89315ac650_.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 2d89315ac650
|
||||
Revises: bc496c0a0279
|
||||
Create Date: 2023-04-15 20:43:44.218020
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2d89315ac650'
|
||||
down_revision = 'bc496c0a0279'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_index(op.f('ix_partner_subscription_end_at'), 'partner_subscription', ['end_at'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_partner_subscription_end_at'), table_name='partner_subscription')
|
||||
# ### end Alembic commands ###
|
29
app/migrations/versions/2023_041916_01e2997e90d3_.py
Normal file
29
app/migrations/versions/2023_041916_01e2997e90d3_.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 01e2997e90d3
|
||||
Revises: 893c0d18475f
|
||||
Create Date: 2023-04-19 16:09:11.851588
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '01e2997e90d3'
|
||||
down_revision = '893c0d18475f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('public_domain', sa.Column('use_as_reverse_alias', sa.Boolean(), server_default='0', nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('public_domain', 'use_as_reverse_alias')
|
||||
# ### end Alembic commands ###
|
25
app/migrations/versions/2023_042011_2634b41f54db_.py
Normal file
25
app/migrations/versions/2023_042011_2634b41f54db_.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 2634b41f54db
|
||||
Revises: 01e2997e90d3, 2d89315ac650
|
||||
Create Date: 2023-04-20 11:47:43.048536
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2634b41f54db'
|
||||
down_revision = ('01e2997e90d3', '2d89315ac650')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
4167
app/poetry.lock
generated
4167
app/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -95,13 +95,13 @@ webauthn = "^0.4.7"
|
||||
pyspf = "^2.0.14"
|
||||
Flask-Limiter = "^1.4"
|
||||
memory_profiler = "^0.57.0"
|
||||
gevent = "^21.12.0"
|
||||
gevent = "22.10.2"
|
||||
aiospamc = "^0.6.1"
|
||||
email_validator = "^1.1.1"
|
||||
PGPy = "0.5.4"
|
||||
coinbase-commerce = "^1.0.1"
|
||||
requests = "^2.25.1"
|
||||
newrelic = "^7.10.0"
|
||||
newrelic = "8.8.0"
|
||||
flanker = "^0.9.11"
|
||||
pyre2 = "^0.3.6"
|
||||
tldextract = "^3.1.2"
|
||||
@ -110,7 +110,7 @@ twilio = "^7.3.2"
|
||||
Deprecated = "^1.2.13"
|
||||
cryptography = "37.0.1"
|
||||
SQLAlchemy = "1.3.24"
|
||||
redis = "^4.3.4"
|
||||
redis = "^4.5.3"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^7.0.0"
|
||||
|
@ -155,10 +155,8 @@ $(".pin-alias").change(async function () {
|
||||
}
|
||||
});
|
||||
|
||||
$(".save-note").on("click", async function () {
|
||||
let oldValue;
|
||||
let aliasId = $(this).data("alias");
|
||||
let note = $(`#note-${aliasId}`).val();
|
||||
async function handleNoteChange(aliasId, aliasEmail) {
|
||||
const note = document.getElementById(`note-${aliasId}`).value;
|
||||
|
||||
try {
|
||||
let res = await fetch(`/api/aliases/${aliasId}`, {
|
||||
@ -172,26 +170,27 @@ $(".save-note").on("click", async function () {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toastr.success(`Saved`);
|
||||
toastr.success(`Description saved for ${aliasEmail}`);
|
||||
} else {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
} catch (e) {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
$(".save-mailbox").on("click", async function () {
|
||||
let oldValue;
|
||||
let aliasId = $(this).data("alias");
|
||||
let mailbox_ids = $(`#mailbox-${aliasId}`).val();
|
||||
function handleNoteFocus(aliasId) {
|
||||
document.getElementById(`note-focus-message-${aliasId}`).classList.remove('d-none');
|
||||
}
|
||||
|
||||
function handleNoteBlur(aliasId) {
|
||||
document.getElementById(`note-focus-message-${aliasId}`).classList.add('d-none');
|
||||
}
|
||||
|
||||
async function handleMailboxChange(aliasId, aliasEmail) {
|
||||
const selectedOptions = document.getElementById(`mailbox-${aliasId}`).selectedOptions;
|
||||
const mailbox_ids = Array.from(selectedOptions).map((selectedOption) => selectedOption.value);
|
||||
|
||||
if (mailbox_ids.length === 0) {
|
||||
toastr.error("You must select at least a mailbox", "Error");
|
||||
@ -210,25 +209,18 @@ $(".save-mailbox").on("click", async function () {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toastr.success(`Mailbox Updated`);
|
||||
toastr.success(`Mailbox updated for ${aliasEmail}`);
|
||||
} else {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
} catch (e) {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
$(".save-alias-name").on("click", async function () {
|
||||
let aliasId = $(this).data("alias");
|
||||
let name = $(`#alias-name-${aliasId}`).val();
|
||||
async function handleDisplayNameChange(aliasId, aliasEmail) {
|
||||
const name = document.getElementById(`alias-name-${aliasId}`).value;
|
||||
|
||||
try {
|
||||
let res = await fetch(`/api/aliases/${aliasId}`, {
|
||||
@ -242,7 +234,7 @@ $(".save-alias-name").on("click", async function () {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toastr.success(`Alias Name Saved`);
|
||||
toastr.success(`Display name saved for ${aliasEmail}`);
|
||||
} else {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
}
|
||||
@ -250,24 +242,41 @@ $(".save-alias-name").on("click", async function () {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
function handleDisplayNameFocus(aliasId) {
|
||||
document.getElementById(`display-name-focus-message-${aliasId}`).classList.remove('d-none');
|
||||
}
|
||||
|
||||
function handleDisplayNameBlur(aliasId) {
|
||||
document.getElementById(`display-name-focus-message-${aliasId}`).classList.add('d-none');
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#filter-app',
|
||||
delimiters: ["[[", "]]"], // necessary to avoid conflict with jinja
|
||||
data: {
|
||||
showFilter: false
|
||||
showFilter: false,
|
||||
showStats: false
|
||||
},
|
||||
methods: {
|
||||
async toggleFilter() {
|
||||
let that = this;
|
||||
that.showFilter = !that.showFilter;
|
||||
store.set('showFilter', that.showFilter);
|
||||
},
|
||||
|
||||
async toggleStats() {
|
||||
let that = this;
|
||||
that.showStats = !that.showStats;
|
||||
store.set('showStats', that.showStats);
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (store.get("showFilter"))
|
||||
this.showFilter = true;
|
||||
|
||||
if (store.get("showStats"))
|
||||
this.showStats = true;
|
||||
}
|
||||
});
|
@ -8,7 +8,8 @@ function enableDragDropForPGPKeys(inputID) {
|
||||
let files = event.dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
let file = files[i];
|
||||
if(file.type !== 'text/plain'){
|
||||
const isValidPgpFile = file.type === 'text/plain' || file.name.endsWith('.asc') || file.name.endsWith('.pub') || file.name.endsWith('.pgp') || file.name.endsWith('.key');
|
||||
if (!isValidPgpFile) {
|
||||
toastr.warning(`File ${file.name} is not a public key file`);
|
||||
continue;
|
||||
}
|
||||
@ -16,6 +17,7 @@ function enableDragDropForPGPKeys(inputID) {
|
||||
reader.onloadend = onFileLoaded;
|
||||
reader.readAsBinaryString(file);
|
||||
}
|
||||
dropArea.classList.remove("dashed-outline");
|
||||
}
|
||||
|
||||
function onFileLoaded(event) {
|
||||
@ -24,5 +26,20 @@ function enableDragDropForPGPKeys(inputID) {
|
||||
}
|
||||
|
||||
const dropArea = $(inputID).get(0);
|
||||
dropArea.addEventListener("dragenter", (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
dropArea.classList.add("dashed-outline");
|
||||
});
|
||||
dropArea.addEventListener("dragover", (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
dropArea.classList.add("dashed-outline");
|
||||
});
|
||||
dropArea.addEventListener("dragleave", (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
dropArea.classList.remove("dashed-outline");
|
||||
});
|
||||
dropArea.addEventListener("drop", drop, false);
|
||||
}
|
16
app/static/package-lock.json
generated
vendored
16
app/static/package-lock.json
generated
vendored
@ -69,12 +69,12 @@
|
||||
"font-awesome": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz",
|
||||
"integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM="
|
||||
"integrity": "sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg=="
|
||||
},
|
||||
"htmx.org": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.6.1.tgz",
|
||||
"integrity": "sha512-i+1k5ee2eFWaZbomjckyrDjUpa3FMDZWufatUSBmmsjXVksn89nsXvr1KLGIdAajiz+ZSL7TE4U/QaZVd2U2sA=="
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.7.0.tgz",
|
||||
"integrity": "sha512-wIQ3yNq7yiLTm+6BhV7Z8qKKTzEQv9xN/I4QsN5FvdGi69SNWTsSMlhH69HPa1rpZ8zSq1A/e7gTbTySxliP8g=="
|
||||
},
|
||||
"intro.js": {
|
||||
"version": "2.9.3",
|
||||
@ -82,9 +82,9 @@
|
||||
"integrity": "sha512-hC+EXWnEuJeA3CveGMat3XHePd2iaXNFJIVfvJh2E9IzBMGLTlhWvPIVHAgKlOpO4lNayCxEqzr4N02VmHFr9Q=="
|
||||
},
|
||||
"jquery": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz",
|
||||
"integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg=="
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.4.tgz",
|
||||
"integrity": "sha512-v28EW9DWDFpzcD9O5iyJXg3R3+q+mET5JhnjJzQUZMHOv67bpSIHq81GEYpPNZHG+XXHsfSme3nxp/hndKEcsQ=="
|
||||
},
|
||||
"multiple-select": {
|
||||
"version": "1.5.2",
|
||||
@ -107,7 +107,7 @@
|
||||
"toastr": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/toastr/-/toastr-2.1.4.tgz",
|
||||
"integrity": "sha1-i0O+ZPudDEFIcURvLbjoyk6V8YE=",
|
||||
"integrity": "sha512-LIy77F5n+sz4tefMmFOntcJ6HL0Fv3k1TDnNmFZ0bU/GcvIIfy6eG2v7zQmMiYgaalAiUv75ttFrPn5s0gyqlA==",
|
||||
"requires": {
|
||||
"jquery": ">=1.12.0"
|
||||
}
|
||||
|
5
app/static/style.css
vendored
5
app/static/style.css
vendored
@ -218,3 +218,8 @@ textarea.parsley-error {
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* dashed outline to indicate droppable area */
|
||||
.dashed-outline {
|
||||
outline: 4px dashed gray;
|
||||
}
|
@ -23,7 +23,7 @@
|
||||
<!-- Yandex -->
|
||||
<meta name="yandex-verification" content="c9e5d4d68bc983a1" />
|
||||
<meta name="description"
|
||||
content="Protect your email address with email ALIAS. Create a different email alias for each website. No more phishing, spams."/>
|
||||
content="Protect your email address with email ALIAS. Create a different email alias for each website. No more phishing, or spam."/>
|
||||
<link rel="icon" href="/static/favicon.ico" type="image/x-icon" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico" />
|
||||
<link rel="canonical" href="{{ CANONICAL_URL }}" />
|
||||
|
@ -43,7 +43,7 @@
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label class="form-label">PGP Public Key</label>
|
||||
<textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ contact.pgp_public_key or "" }}</textarea>
|
||||
<textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="(Drag and drop or paste your pgp public key here) -----BEGIN PGP PUBLIC KEY BLOCK-----">{{ contact.pgp_public_key or "" }}</textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}
|
||||
disabled {% endif %} value="save">
|
||||
|
@ -266,7 +266,9 @@
|
||||
<i>dkim._domainkey.{{ custom_domain.domain }}</i> as domain value instead.
|
||||
<br />
|
||||
If you are using a subdomain, e.g. <i>subdomain.domain.com</i>,
|
||||
you need to use <i>dkim._domainkey.subdomain</i> as domain value instead.
|
||||
you need to use <i>dkim._domainkey.subdomain</i> as the domain instead.
|
||||
<br />
|
||||
That means, if your domain is <i>mail.domain.com</i> you should enter <i>dkim._domainkey.mail.domain.com</i> as the Domain.
|
||||
<br />
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
|
@ -31,63 +31,11 @@
|
||||
{% block title %}Alias{% endblock %}
|
||||
{% block default_content %}
|
||||
|
||||
<!-- Global Stats -->
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Aliases</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">All time</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_alias }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Forwarded</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_forward }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Replies/Sent</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_reply }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Blocked</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_block }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END Global Stats -->
|
||||
<!-- Controls: buttons & search -->
|
||||
<div id="filter-app">
|
||||
<div class="row mb-3">
|
||||
<div class="col d-flex">
|
||||
<div>
|
||||
<div class="col d-flex flex-wrap justify-content-between">
|
||||
<div class="mb-1">
|
||||
<div class="btn-group" role="group">
|
||||
<form method="post">
|
||||
{{ csrf_form.csrf_token }}
|
||||
@ -141,17 +89,86 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-left: auto">
|
||||
<div>
|
||||
<div class="btn-group">
|
||||
<a v-if="!showFilter"
|
||||
@click="toggleFilter()"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="fe fe-chevrons-down"></i> Filters
|
||||
<a @click="toggleStats()" class="btn btn-outline-secondary">
|
||||
<span v-if="!showStats">
|
||||
<i class="fe fe-chevrons-down"></i>
|
||||
Show stats
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fe fe-chevrons-up"></i>
|
||||
Hide stats
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a @click="toggleFilter()" class="btn btn-outline-secondary">
|
||||
<span v-if="!showFilter">
|
||||
<i class="fe fe-chevrons-down"></i>
|
||||
Show filters
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fe fe-chevrons-up"></i>
|
||||
Hide filters
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Global Stats -->
|
||||
<div class="row" v-if="showStats">
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Aliases</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">All time</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_alias }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Forwarded</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_forward }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Replies/Sent</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_reply }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Blocked</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_block }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END Global Stats -->
|
||||
<div class="row mb-2" v-if="showFilter" id="filter-control">
|
||||
<!-- Filter Control -->
|
||||
<div class="col d-flex">
|
||||
@ -223,11 +240,6 @@
|
||||
<a href="{{ url_for('dashboard.index') }}"
|
||||
class="btn btn-outline-secondary">Reset</a>
|
||||
{% endif %}
|
||||
<a v-if="showFilter"
|
||||
@click="toggleFilter()"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="fe fe-chevrons-up"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -342,17 +354,11 @@
|
||||
</div>
|
||||
<!-- END Email Activity -->
|
||||
<div class="small-text mt-1">
|
||||
Alias description
|
||||
Alias description <span id="note-focus-message-{{ alias.id }}" class="d-none font-italic">(automatically saved when you click outside the field)</span>
|
||||
</div>
|
||||
<div class="d-flex mb-2">
|
||||
<div class="flex-grow-1 mr-2">
|
||||
<textarea id="note-{{ alias.id }}" name="note" class="form-control" style="font-size: 12px" rows="2" placeholder="e.g. where the alias is used or why is it created">{{ alias.note or "" }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<a data-alias="{{ alias.id }}"
|
||||
class="save-note btn btn-sm btn-outline-success w-100">
|
||||
Save
|
||||
</a>
|
||||
<textarea id="note-{{ alias.id }}" name="note" class="form-control" style="font-size: 12px" rows="2" placeholder="e.g. where the alias is used or why is it created" onchange="handleNoteChange({{ alias.id }}, '{{ alias.email }}')" onfocus="handleNoteFocus({{ alias.id }})" onblur="handleNoteBlur({{ alias.id }})">{{ alias.note or "" }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Send Email && More button -->
|
||||
@ -421,7 +427,8 @@
|
||||
data-width="100%"
|
||||
class="mailbox-select"
|
||||
multiple
|
||||
name="mailbox">
|
||||
name="mailbox"
|
||||
onchange="handleMailboxChange({{ alias.id }}, '{{ alias.email }}')">
|
||||
{% for mailbox in mailboxes %}
|
||||
|
||||
<option value="{{ mailbox.id }}" {% if alias_info.contain_mailbox(mailbox.id) %}
|
||||
@ -431,12 +438,6 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<a data-alias="{{ alias.id }}"
|
||||
class="save-mailbox btn btn-sm btn-outline-info w-100">
|
||||
Update
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% elif alias_info.mailbox != None and alias_info.mailbox.email != current_user.email %}
|
||||
<div class="small-text">
|
||||
@ -448,19 +449,18 @@
|
||||
title="When sending an email from this alias, the email will have 'Display Name <{{ alias.email }}>' as sender.">
|
||||
Display name
|
||||
<i class="fe fe-help-circle"></i>
|
||||
<span id="display-name-focus-message-{{ alias.id }}"
|
||||
class="d-none font-italic">(automatically saved when you click outside the field or press Enter)</span>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="flex-grow-1 mr-2">
|
||||
<input id="alias-name-{{ alias.id }}"
|
||||
value="{{ alias.name or '' }}"
|
||||
class="form-control"
|
||||
placeholder="{{ alias.custom_domain.name or "Alias name" }}">
|
||||
</div>
|
||||
<div>
|
||||
<a data-alias="{{ alias.id }}"
|
||||
class="save-alias-name btn btn-sm btn-outline-primary w-100">
|
||||
Save
|
||||
</a>
|
||||
placeholder="{{ alias.custom_domain.name or "Alias name" }}"
|
||||
onchange="handleDisplayNameChange({{ alias.id }}, '{{ alias.email }}')"
|
||||
onfocus="handleDisplayNameFocus({{ alias.id }})"
|
||||
onblur="handleDisplayNameBlur({{ alias.id }})">
|
||||
</div>
|
||||
</div>
|
||||
{% if alias.mailbox_support_pgp() %}
|
||||
|
@ -112,7 +112,7 @@
|
||||
{{ csrf_form.csrf_token }}
|
||||
<div class="form-group">
|
||||
<label class="form-label">PGP Public Key</label>
|
||||
<textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea>
|
||||
<textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="(Drag and drop or paste your pgp public key here) -----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea>
|
||||
</div>
|
||||
<input type="hidden" name="form-name" value="pgp">
|
||||
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}
|
||||
|
@ -8,10 +8,11 @@
|
||||
<script>
|
||||
if (window.Paddle === undefined) {
|
||||
console.log("cannot load Paddle from CDN");
|
||||
document.write('<script src="/static/vendor/paddle.js"><\/script>')
|
||||
// split string to avoid djlint incorrectly formatting the file
|
||||
document.write('<' + 'script src="/static/vendor/paddle.js"><\/script' + '>');
|
||||
}
|
||||
</script>
|
||||
<style type="text/css">
|
||||
</script>
|
||||
<style type="text/css">
|
||||
html.mvc__a.mvc__lot.mvc__of.mvc__classes.mvc__to.mvc__increase.mvc__the.mvc__odds.mvc__of.mvc__winning.mvc__specificity, html.mvc__a.mvc__lot.mvc__of.mvc__classes.mvc__to.mvc__increase.mvc__the.mvc__odds.mvc__of.mvc__winning.mvc__specificity > body {
|
||||
position: static;
|
||||
}
|
||||
@ -25,191 +26,737 @@
|
||||
[data-toggle="collapse"]:not(.collapsed) .if-collapsed {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
.btn-no-pointer {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.tab-yearly__badge {
|
||||
top: -8px !important;
|
||||
left: 52px !important;
|
||||
}
|
||||
|
||||
.border-2 {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
.text-start {
|
||||
text-align: start !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block announcement %}
|
||||
|
||||
{# TODO: to remove#}
|
||||
{# <div class="alert alert-danger text-center mb-0" role="alert">#}
|
||||
{# Our payment provider Paddle is experiencing#}
|
||||
{# <a href="https://paddle.status.io" target="_blank">server issue <i class="fe fe-external-link"></i></a>#}
|
||||
{# that can make our checkout page unusable. <br />#}
|
||||
{# Please retry later and sorry for this issue!#}
|
||||
{# </div>#}
|
||||
{# TODO: to remove#}
|
||||
{# <div class="alert alert-danger text-center mb-0" role="alert">#}
|
||||
{# Our payment provider Paddle is experiencing#}
|
||||
{# <a href="https://paddle.status.io" target="_blank">server issue <i class="fe fe-external-link"></i></a>#}
|
||||
{# that can make our checkout page unusable. <br />#}
|
||||
{# Please retry later and sorry for this issue!#}
|
||||
{# </div>#}
|
||||
{% endblock %}
|
||||
{% block default_content %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-6 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<div class="h3">Premium</div>
|
||||
<ul class="list-unstyled leading-loose mb-3">
|
||||
<li>
|
||||
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Unlimited aliases
|
||||
</li>
|
||||
<li>
|
||||
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Unlimited custom domains
|
||||
</li>
|
||||
<li>
|
||||
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Catch-all (or wildcard) aliases
|
||||
</li>
|
||||
<li>
|
||||
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Up to 50 directories (or usernames)
|
||||
</li>
|
||||
<li>
|
||||
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Unlimited mailboxes
|
||||
</li>
|
||||
<li>
|
||||
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
PGP Encryption
|
||||
</li>
|
||||
</ul>
|
||||
<div class="small-text">
|
||||
More information on our
|
||||
<a href="https://simplelogin.io/pricing" target="_blank" rel="noopener noreferrer">
|
||||
Pricing
|
||||
Page <i class="fe fe-external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-6">
|
||||
{% if manual_sub %}
|
||||
<div class="pb-8">
|
||||
<div class="text-center mx-md-auto mb-8 mt-6">
|
||||
<h1>Upgrade to unlock premium features</h1>
|
||||
</div>
|
||||
{% if manual_sub %}
|
||||
|
||||
<div class="alert alert-info">
|
||||
You currently have a subscription until <b>{{ manual_sub.end_at.format("YYYY-MM-DD") }}</b>
|
||||
({{ (manual_sub.end_at - now).days }} days left).
|
||||
<br />
|
||||
Please note that the time left will <b>not</b> be taken into account in a new subscription.
|
||||
</div>
|
||||
<hr />
|
||||
{% endif %}
|
||||
{% if proton_upgrade %}
|
||||
<div class="alert alert-info mt-0 mb-6">
|
||||
You currently have a subscription until <b>{{ manual_sub.end_at.format("YYYY-MM-DD") }}</b>
|
||||
({{ (manual_sub.end_at - now).days }} days left).
|
||||
<br />
|
||||
Please note that the time left will <b>not</b> be taken into account in a new subscription.
|
||||
</div>
|
||||
<hr />
|
||||
{% endif %}
|
||||
{% set sub = current_user.get_paddle_subscription() %}
|
||||
{% if sub and sub.cancelled %}
|
||||
|
||||
<div id="proton-upgrade">
|
||||
<h4>Proton Unlimited, Business and Visionary plans include SimpleLogin premium and more!</h4>
|
||||
<a class="btn btn-primary" role="button" href="https://account.proton.me/u/0/mail/upgrade">
|
||||
<b>Upgrade your Proton account</b>
|
||||
</a>
|
||||
<p class="mt-2 small">
|
||||
Starts at $9.99/month (billed yearly), starting with 500GB of storage, VPN, encrypted
|
||||
calendar & file storage and more.
|
||||
</p>
|
||||
<div class="middle-line my-5 h4">OR</div>
|
||||
<div id="normal-upgrade-button">
|
||||
<a class="btn btn-secondary collapsed" data-toggle="collapse" href="#normal-upgrade" role="button">
|
||||
Upgrade your SimpleLogin account
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</a>
|
||||
<p class="mt-2 small">Starts at $2.5/month (billed yearly)</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="normal-upgrade" class="{% if proton_upgrade %} collapse{% endif %}">
|
||||
<div class="display-6 my-3">
|
||||
🔐 Secure payments by
|
||||
<a href="https://paddle.com" target="_blank" rel="noopener noreferrer">
|
||||
Paddle <i class="fe fe-external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% set sub = current_user.get_paddle_subscription() %}
|
||||
{% if sub and sub.cancelled %}
|
||||
<div class="alert alert-primary mt-0 mb-6" role="alert">
|
||||
You have an active subscription until {{ sub.next_bill_date.strftime("%Y-%m-%d") }}.
|
||||
<br />
|
||||
Please note that if you re-subscribe now, this will be a completely
|
||||
new subscription and
|
||||
your payment method will be charged <b>immediately</b>.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if coinbase_sub %}
|
||||
|
||||
<div class="alert alert-primary" role="alert">
|
||||
You have an active subscription until {{ sub.next_bill_date.strftime("%Y-%m-%d") }}.
|
||||
<br />
|
||||
Please note that if you re-subscribe now, this will be a completely
|
||||
new subscription and
|
||||
your payment method will be charged <b>immediately</b>.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if coinbase_sub %}
|
||||
<div class="alert alert-info mt-0 mb-6">
|
||||
You currently have a Coinbase subscription until <b>{{ coinbase_sub.end_at.format("YYYY-MM-DD") }}</b>
|
||||
({{ (coinbase_sub.end_at - now).days }} days left).
|
||||
<br />
|
||||
Please note that the time left will <b>not</b> be taken into account in a new Paddle subscription.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="nav btn-group mb-4 justify-content-center position-relative flex-nowrap d-flex"
|
||||
id="pills-tab"
|
||||
role="tablist">
|
||||
<a class="btn btn-outline-primary flex-grow-0 px-8 py-2"
|
||||
id="monthly-plan-tab"
|
||||
data-toggle="tab"
|
||||
href="#monthly-plan"
|
||||
role="tab"
|
||||
aria-controls="monthly-plan"
|
||||
aria-selected="false">Monthly</a>
|
||||
<a class="btn btn-outline-primary flex-grow-0 px-8 py-2 position-relative active"
|
||||
id="yearly-plan-tab"
|
||||
data-toggle="tab"
|
||||
href="#yearly-plan"
|
||||
role="tab"
|
||||
aria-controls="yearly-plan"
|
||||
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="tab-content mb-8">
|
||||
<!-- monthly tab content -->
|
||||
<div class="tab-pane"
|
||||
id="monthly-plan"
|
||||
role="tabpanel"
|
||||
aria-labelledby="monthly-plan-tab">
|
||||
<div class="row row-cards">
|
||||
<!-- monthly free plan -->
|
||||
<div class="{{ 'col-md-6 col-lg-4' if proton_upgrade else 'col-md-6' }}">
|
||||
<div class="card card-md flex-grow-1">
|
||||
<div class="card-body">
|
||||
<div class="text-center">
|
||||
<div class="h3">Free</div>
|
||||
<div class="h3 my-3">$0</div>
|
||||
<div class="text-center mt-4 mb-6">
|
||||
{% set sub = current_user.get_paddle_subscription() %}
|
||||
<button class="{{ 'invisible' if sub or manual_sub or coinbase_sub }} btn btn-lg btn-outline-secondary w-100 btn-no-pointer"
|
||||
aria-disabled="true"
|
||||
disabled>
|
||||
Current plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
10 aliases
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
1 mailbox
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END monthly free plan -->
|
||||
<!-- monthly premium plan -->
|
||||
<div class="{{ 'col-md-6 col-lg-4' if proton_upgrade else 'col-md-6' }}">
|
||||
<div class="card card-md flex-grow-1 border-primary border-2">
|
||||
<div class="card-body">
|
||||
<div class="text-center">
|
||||
<div class="h3">SimpleLogin Premium</div>
|
||||
<div class="h3 my-3">$4 / month</div>
|
||||
<div class="text-center mt-4 mb-6">
|
||||
<button class="btn btn-primary btn-lg w-100"
|
||||
onclick="upgradePaddle({{ PADDLE_MONTHLY_PRODUCT_ID }})">
|
||||
Upgrade to Premium
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited aliases
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited mailboxes
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Custom domains: bring your own domain to create aliases like contact@your-domain.com
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Catch-all (or wildcard) domain
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Initiate a new email from your alias
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
5 subdomains
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
50 directories
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
PGP Encryption
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END monthly premium plan -->
|
||||
<!-- monthly Proton plan -->
|
||||
{% if proton_upgrade %}
|
||||
|
||||
<div class="alert alert-info">
|
||||
You currently have a Coinbase subscription until <b>{{ coinbase_sub.end_at.format("YYYY-MM-DD") }}</b>
|
||||
({{ (coinbase_sub.end_at - now).days }} days left).
|
||||
<br />
|
||||
Please note that the time left will <b>not</b> be taken into account in a new Paddle subscription.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-3">
|
||||
Paddle supports bank cards
|
||||
(Mastercard, Visa, American Express, etc) and PayPal.
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="upgrade({{ PADDLE_YEARLY_PRODUCT_ID }})">
|
||||
Yearly billing
|
||||
<span class="badge badge-success">Save $18</span>
|
||||
<br />
|
||||
<span style="font-size: 18px">$30/year</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="upgrade({{ PADDLE_MONTHLY_PRODUCT_ID }})">
|
||||
Monthly billing
|
||||
<br />
|
||||
<b>
|
||||
$4/month
|
||||
</b>
|
||||
</button>
|
||||
<hr />
|
||||
<i class="fa fa-bitcoin"></i>
|
||||
Payment via
|
||||
<a href="https://commerce.coinbase.com/?lang=en" target="_blank" rel="noopener noreferrer">
|
||||
Coinbase Commerce<i class="fe fe-external-link"></i>
|
||||
</a>
|
||||
<br />
|
||||
Currently Bitcoin, Bitcoin Cash, Dai, Ethereum, Litecoin and USD Coin are supported.
|
||||
<br />
|
||||
<a class="btn btn-outline-primary" href="{{ url_for('dashboard.coinbase_checkout_route') }}" target="_blank" rel="noopener noreferrer">
|
||||
Yearly billing - Crypto
|
||||
<br />
|
||||
$30/year
|
||||
<i class="fe fe-external-link"></i>
|
||||
</a>
|
||||
<hr />
|
||||
If you have bought a coupon, please go to the
|
||||
<a href="{{ url_for('dashboard.coupon_route') }}">coupon page</a>
|
||||
to apply the coupon code.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<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 my-3">Starts at $11.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"
|
||||
target="_blank">Upgrade your Proton account</a>
|
||||
</div>
|
||||
</div>
|
||||
<p>Proton Unlimited / Business plans include:</p>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
SimpleLogin Premium
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
500 GB storage
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
15 email addresses
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited folders, labels, and filters
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited messages per day
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
15 email addresses
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
20 Calendars
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
10 high-speed VPN connections
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
3 custom email domains
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- END monthly Proton plan -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- END monthly tab content -->
|
||||
<!-- yearly tab content -->
|
||||
<div class="tab-pane show active"
|
||||
id="yearly-plan"
|
||||
role="tabpanel"
|
||||
aria-labelledby="yearly-plan-tab">
|
||||
<div class="row row-cards">
|
||||
<!-- yearly free plan (identical to monthly) -->
|
||||
<div class="{{ 'col-md-6 col-lg-4' if proton_upgrade else 'col-md-6' }}">
|
||||
<div class="card card-md flex-grow-1">
|
||||
<div class="card-body">
|
||||
<div class="text-center">
|
||||
<div class="h3">Free</div>
|
||||
<div class="h3 my-3">$0</div>
|
||||
<div class="text-center mt-4 mb-6">
|
||||
{% set sub = current_user.get_paddle_subscription() %}
|
||||
<button class="{{ 'invisible' if sub or manual_sub or coinbase_sub }} btn btn-lg btn-outline-secondary w-100 btn-no-pointer"
|
||||
aria-disabled="true"
|
||||
disabled>
|
||||
Current plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
10 aliases
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
1 mailbox
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END yearly free plan -->
|
||||
<!-- yearly premium plan -->
|
||||
<div class="{{ 'col-md-6 col-lg-4' if proton_upgrade else 'col-md-6' }}">
|
||||
<div class="card card-md flex-grow-1 border-primary border-2">
|
||||
<div class="card-body">
|
||||
<div class="text-center">
|
||||
<div class="h3">SimpleLogin Premium</div>
|
||||
<div class="h3 my-3">$30 / year</div>
|
||||
<div class="text-center mt-4 mb-6">
|
||||
<button class="btn btn-primary btn-lg w-100"
|
||||
onclick="upgradePaddle({{ PADDLE_YEARLY_PRODUCT_ID }})">
|
||||
Upgrade to Premium
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited aliases
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited mailboxes
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Custom domains: bring your own domain to create aliases like contact@your-domain.com
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Catch-all (or wildcard) domain
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Initiate a new email from your alias
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
5 subdomains
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
50 directories
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
PGP Encryption
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END yearly premium plan -->
|
||||
<!-- yearly Proton plan -->
|
||||
{% if proton_upgrade %}
|
||||
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<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 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"
|
||||
target="_blank">Upgrade your Proton account</a>
|
||||
</div>
|
||||
</div>
|
||||
<p>Proton Unlimited / Business plans include:</p>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
SimpleLogin Premium
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
500 GB storage
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
15 email addresses/aliases
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited folders, labels, and filters
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited messages per day
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
15 email addresses/aliases
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
20 Calendars
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
10 high-speed VPN connections
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
3 custom email domains
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- END yearly Proton plan -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- END yearly tab content -->
|
||||
</div>
|
||||
<hr />
|
||||
<!-- FAQ section -->
|
||||
<div>
|
||||
<h3 class="text-center mb-5 mt-7">Frequently asked questions</h3>
|
||||
<div id="pricing-faq">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-payment-methods">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-payment-methods"
|
||||
aria-controls="pricing-faq-answer-payment-methods"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">Which payment methods (credit cards, PayPal, cryptocurrencies...) do you support?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-payment-methods"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-payment-methods"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
We use <a href="https://paddle.com" target="_blank" rel="noopener noreferrer">Paddle <i class="fe fe-external-link"></i></a> by default for handling payments via credit cards and PayPal. Paddle currently supports the following payment methods:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Cards (including Mastercard, Visa, Maestro, American Express, Discover, Diners Club, JCB, UnionPay, and Mada)
|
||||
</li>
|
||||
<li>
|
||||
PayPal
|
||||
</li>
|
||||
<li>
|
||||
Apple Pay
|
||||
</li>
|
||||
<li>
|
||||
Wire Transfers (ACH/SEPA/BACS)
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
More information can be found on
|
||||
<a href="https://paddle.com/support/which-payment-methods-do-you-support/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Paddle supported payment methods <i class="fe fe-external-link"></i>
|
||||
</a>.
|
||||
</p>
|
||||
<hr />
|
||||
<p>
|
||||
Furthermore we also support cryptocurrencies for the yearly plan via
|
||||
<a href="https://commerce.coinbase.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Coinbase Commerce <i class="fe fe-external-link"></i>
|
||||
</a>, which currently supports Bitcoin, Bitcoin Cash, DAI, ApeCoin, Dogecoin, Ethereum, Litecoin, SHIBA INU, Tether and USD Coin.
|
||||
</p>
|
||||
<p>
|
||||
In the future, we are going to support Monero as well. In the meantime, please send us an email at <a href="mailto:support@simplelogin.zendesk.com">support@simplelogin.zendesk.com</a> if you want to use this cryptocurrency.
|
||||
</p>
|
||||
<div class="d-flex justify-content-center">
|
||||
<a class="btn btn-outline-primary text-center"
|
||||
href="{{ url_for('dashboard.coinbase_checkout_route') }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Upgrade to Premium - cryptocurrency
|
||||
<br />
|
||||
$30 / year
|
||||
<i class="fe fe-external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-coupon">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-coupon"
|
||||
aria-controls="pricing-faq-answer-coupon"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">Where can I redeem / buy a coupon?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-coupon"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-coupon"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
To redeem or buy a coupon, please go to the
|
||||
<a href="{{ url_for('dashboard.coupon_route') }}">coupon page</a>. The coupon code can be used by you or given to someone as a gift.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-aliases-sub-stopped">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-aliases-sub-stopped"
|
||||
aria-controls="pricing-faq-answer-aliases-sub-stopped"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">What happens to my aliases when I stop the subscription?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-aliases-sub-stopped"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-aliases-sub-stopped"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
When your subscription ends, all aliases you created continue working normally, both on receiving and
|
||||
sending emails. Concretely:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
All aliases/domains/directories/mailboxes you have created are kept and continue working normally.
|
||||
</li>
|
||||
<li>
|
||||
You cannot create new aliases if you exceed the free plan limit, i.e. have more than 10 aliases.
|
||||
</li>
|
||||
<li>
|
||||
As features like catch-all or directory allow you to create aliases on-the-fly, those aliases cannot be automatically created if you have more than 10 aliases.
|
||||
</li>
|
||||
<li>
|
||||
You cannot add new domain, directory or mailbox.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
For example, if you have 100 aliases by the time your subscription ends, these 100 aliases will continue receiving and sending emails normally. You cannot however create new aliases.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-aliases-max">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-aliases-max"
|
||||
aria-controls="pricing-faq-answer-aliases-max"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">What happens when I reach the maximum number of alias in free plan?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-aliases-max"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-aliases-max"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
If you are in the free plan, you cannot create new aliases when you reach the maximum number of aliases
|
||||
(i.e. 10 aliases).
|
||||
<br>
|
||||
Aliases that would otherwise be created automatically via the catch-all domain or directory feature also cannot be created.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-discounts">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-discounts"
|
||||
aria-controls="pricing-faq-answer-discounts"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">Do you offer discounts?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-discounts"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-discounts"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
We offer important discounts or free premium for:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
students, professors or technical staffs working at an educational institute
|
||||
</li>
|
||||
<li>
|
||||
activists, dissidents or journalists
|
||||
</li>
|
||||
<li>
|
||||
charity organizations
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Please send us an email at <a href="mailto:support@simplelogin.zendesk.com">support@simplelogin.zendesk.com</a> for more info.
|
||||
</p>
|
||||
<p>
|
||||
We used to offer free premium accounts for students but this program ended at June 17 2021. Please note this doesn't affect existing accounts who have already benefited from the program or requests sent before this date.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-refund">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-refund"
|
||||
aria-controls="pricing-faq-answer-refund"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">Do you have a refund policy?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-refund"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-refund"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
No we don't have a refund policy because SimpleLogin has a trial period where you can try all premium features.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-family">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-family"
|
||||
aria-controls="pricing-faq-answer-family"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">Do you have a family plan?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-family"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-family"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
No we don't have a family plan but offer 30% reduction for additional subscriptions. Please contact us at <a href="mailto:support@simplelogin.zendesk.com">support@simplelogin.zendesk.com</a> for more information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-other-ways">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-other-ways"
|
||||
aria-controls="pricing-faq-answer-other-ways"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">Are there other ways to buy SimpleLogin subscriptions?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-other-ways"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-other-ways"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
Yes you can also buy SimpleLogin subscription coupon via <a href="https://proxysto.re/en/index.html" target="_blank">ProxyStore <i class="fe fe-external-link"></i></a>, our official reseller.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END FAQ section -->
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
Paddle.Setup({vendor: {{ PADDLE_VENDOR_ID }}});
|
||||
|
||||
function upgrade(productId) {
|
||||
bootbox.dialog({
|
||||
title: `Payment with credit card or PayPal via Paddle`,
|
||||
message: `Paddle will ask for an email address for sending out the invoices, please feel free to use an alias. <br />
|
||||
You don't have to use your SimpleLogin account email address`,
|
||||
size: 'large',
|
||||
onEscape: true,
|
||||
backdrop: true,
|
||||
buttons: {
|
||||
got_it: {
|
||||
label: 'Got it!',
|
||||
className: 'btn-outline-primary',
|
||||
callback: function () {
|
||||
function upgradePaddle(productId) {
|
||||
Paddle.Checkout.open({
|
||||
product: productId,
|
||||
success: "{{ success_url }}",
|
||||
passthrough: "{\"user_id\": {{current_user.id}} }"
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
plausible("visit pricing");
|
||||
</script>
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -15,7 +15,7 @@
|
||||
<div class="col">
|
||||
<h1 class="h3 mb-5">Quarantine & Bounce</h1>
|
||||
<div class="alert alert-info">
|
||||
This page shows all emails that are either refused by your mailbox (bounced) or detected as spams/phishing (quarantine) via our
|
||||
This page shows all emails that are either refused by your mailbox (bounced) or detected as spam/phishing (quarantine) via our
|
||||
<a href="https://simplelogin.io/docs/getting-started/anti-phishing/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">anti-phishing program ↗</a>
|
||||
@ -31,7 +31,7 @@
|
||||
rel="noopener noreferrer">Setting up filter for SimpleLogin emails ↗</a>
|
||||
</li>
|
||||
<li>
|
||||
If the email is flagged as spams/phishing, this means that the sender explicitly states their emails should respect
|
||||
If the email is flagged as spam/phishing, this means that the sender explicitly states their emails should respect
|
||||
<b>DMARC</b> (an email authentication protocol)
|
||||
and any email that violates this should either be quarantined or rejected. If possible, please contact the sender
|
||||
so they can update their DMARC setting or fix their SPF/DKIM that cause the DMARC failure.
|
||||
|
@ -15,5 +15,4 @@
|
||||
<a class="btn btn-primary" href="/">Close</a>
|
||||
</div>
|
||||
</div>
|
||||
<script>plausible("upgraded")</script>
|
||||
{% endblock %}
|
||||
|
@ -31,7 +31,7 @@ Please consider the following options:
|
||||
<a href="{{ disable_alias_link }}">disable the alias</a>
|
||||
or
|
||||
<a href="{{ block_sender_link }}">block the sender</a>
|
||||
if they send too many spams.
|
||||
if they send too many spam emails.
|
||||
</li>
|
||||
</ol>
|
||||
<br />
|
||||
|
@ -12,7 +12,7 @@ Please consider the following options:
|
||||
|
||||
2. If this email is spam, it means your alias {{alias}} is now in the hands of a spammer.
|
||||
You can either disable the alias on {{disable_alias_link}}
|
||||
or block the sender on {{ block_sender_link }} if they send too many spams.
|
||||
or block the sender on {{ block_sender_link }} if they send too many spam emails.
|
||||
|
||||
Please note that the alias can be automatically disabled if too many emails sent to it are bounced.
|
||||
|
||||
|
@ -19,7 +19,7 @@
|
||||
<a href="{{ disable_alias_link }}">disable the alias</a>
|
||||
or
|
||||
<a href="{{ block_sender_link }}">block the sender</a>
|
||||
if they send too many spams.
|
||||
if they send too many spam emails.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
@ -129,3 +129,12 @@ def test_change_name(flask_client):
|
||||
assert r.json["name"] == "new name"
|
||||
|
||||
assert user.name == "new name"
|
||||
|
||||
|
||||
def test_stats(flask_client):
|
||||
login(flask_client)
|
||||
|
||||
r = flask_client.get("/api/stats")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json == {"nb_alias": 1, "nb_block": 0, "nb_forward": 0, "nb_reply": 0}
|
||||
|
20
app/tests/dashboard/test_coupon.py
Normal file
20
app/tests/dashboard/test_coupon.py
Normal file
@ -0,0 +1,20 @@
|
||||
from flask import url_for
|
||||
from app.models import Coupon
|
||||
from app.utils import random_string
|
||||
from tests.utils import login
|
||||
|
||||
|
||||
def test_use_coupon(flask_client):
|
||||
user = login(flask_client)
|
||||
code = random_string(10)
|
||||
Coupon.create(code=code, nb_year=1, commit=True)
|
||||
|
||||
r = flask_client.post(
|
||||
url_for("dashboard.coupon_route"),
|
||||
data={"code": code},
|
||||
)
|
||||
|
||||
assert r.status_code == 302
|
||||
coupon = Coupon.get_by(code=code)
|
||||
assert coupon.used
|
||||
assert coupon.used_by_user_id == user.id
|
@ -1,13 +1,7 @@
|
||||
from app import config
|
||||
from app.db import Session
|
||||
from app.models import User, Job
|
||||
from tests.utils import create_new_user, random_email
|
||||
|
||||
|
||||
def test_available_sl_domains(flask_client):
|
||||
user = create_new_user()
|
||||
|
||||
assert set(user.available_sl_domains()) == {"d1.test", "d2.test", "sl.local"}
|
||||
from tests.utils import random_email
|
||||
|
||||
|
||||
def test_create_from_partner(flask_client):
|
||||
|
130
app/tests/test_domains.py
Normal file
130
app/tests/test_domains.py
Normal file
@ -0,0 +1,130 @@
|
||||
from app.db import Session
|
||||
from app.models import SLDomain, PartnerUser, AliasOptions
|
||||
from app.proton.utils import get_proton_partner
|
||||
from init_app import add_sl_domains
|
||||
from tests.utils import create_new_user, random_token
|
||||
|
||||
|
||||
def setup_module():
|
||||
Session.query(SLDomain).delete()
|
||||
SLDomain.create(
|
||||
domain="hidden", premium_only=False, flush=True, order=5, hidden=True
|
||||
)
|
||||
SLDomain.create(domain="free_non_partner", premium_only=False, flush=True, order=4)
|
||||
SLDomain.create(
|
||||
domain="premium_non_partner", premium_only=True, flush=True, order=3
|
||||
)
|
||||
SLDomain.create(
|
||||
domain="free_partner",
|
||||
premium_only=False,
|
||||
flush=True,
|
||||
partner_id=get_proton_partner().id,
|
||||
order=2,
|
||||
)
|
||||
SLDomain.create(
|
||||
domain="premium_partner",
|
||||
premium_only=True,
|
||||
flush=True,
|
||||
partner_id=get_proton_partner().id,
|
||||
order=1,
|
||||
)
|
||||
Session.commit()
|
||||
|
||||
|
||||
def teardown_module():
|
||||
Session.query(SLDomain).delete()
|
||||
add_sl_domains()
|
||||
|
||||
|
||||
def test_get_non_partner_domains():
|
||||
user = create_new_user()
|
||||
domains = user.get_sl_domains()
|
||||
# Premium
|
||||
assert len(domains) == 2
|
||||
assert domains[0].domain == "premium_non_partner"
|
||||
assert domains[1].domain == "free_non_partner"
|
||||
assert [d.domain for d in domains] == user.available_sl_domains()
|
||||
# Free
|
||||
user.trial_end = None
|
||||
Session.flush()
|
||||
domains = user.get_sl_domains()
|
||||
assert len(domains) == 1
|
||||
assert domains[0].domain == "free_non_partner"
|
||||
assert [d.domain for d in domains] == user.available_sl_domains()
|
||||
|
||||
|
||||
def test_get_free_with_partner_domains():
|
||||
user = create_new_user()
|
||||
user.trial_end = None
|
||||
PartnerUser.create(
|
||||
partner_id=get_proton_partner().id,
|
||||
user_id=user.id,
|
||||
external_user_id=random_token(10),
|
||||
flush=True,
|
||||
)
|
||||
domains = user.get_sl_domains()
|
||||
# Default
|
||||
assert len(domains) == 1
|
||||
assert domains[0].domain == "free_non_partner"
|
||||
assert [d.domain for d in domains] == user.available_sl_domains()
|
||||
# Show partner domains
|
||||
options = AliasOptions(
|
||||
show_sl_domains=True, show_partner_domains=get_proton_partner()
|
||||
)
|
||||
domains = user.get_sl_domains(alias_options=options)
|
||||
assert len(domains) == 2
|
||||
assert domains[0].domain == "free_partner"
|
||||
assert domains[1].domain == "free_non_partner"
|
||||
assert [d.domain for d in domains] == user.available_sl_domains(
|
||||
alias_options=options
|
||||
)
|
||||
# Only partner domains
|
||||
options = AliasOptions(
|
||||
show_sl_domains=False, show_partner_domains=get_proton_partner()
|
||||
)
|
||||
domains = user.get_sl_domains(alias_options=options)
|
||||
assert len(domains) == 1
|
||||
assert domains[0].domain == "free_partner"
|
||||
assert [d.domain for d in domains] == user.available_sl_domains(
|
||||
alias_options=options
|
||||
)
|
||||
|
||||
|
||||
def test_get_premium_with_partner_domains():
|
||||
user = create_new_user()
|
||||
PartnerUser.create(
|
||||
partner_id=get_proton_partner().id,
|
||||
user_id=user.id,
|
||||
external_user_id=random_token(10),
|
||||
flush=True,
|
||||
)
|
||||
domains = user.get_sl_domains()
|
||||
# Default
|
||||
assert len(domains) == 2
|
||||
assert domains[0].domain == "premium_non_partner"
|
||||
assert domains[1].domain == "free_non_partner"
|
||||
assert [d.domain for d in domains] == user.available_sl_domains()
|
||||
# Show partner domains
|
||||
options = AliasOptions(
|
||||
show_sl_domains=True, show_partner_domains=get_proton_partner()
|
||||
)
|
||||
domains = user.get_sl_domains(alias_options=options)
|
||||
assert len(domains) == 4
|
||||
assert domains[0].domain == "premium_partner"
|
||||
assert domains[1].domain == "free_partner"
|
||||
assert domains[2].domain == "premium_non_partner"
|
||||
assert domains[3].domain == "free_non_partner"
|
||||
assert [d.domain for d in domains] == user.available_sl_domains(
|
||||
alias_options=options
|
||||
)
|
||||
# Only partner domains
|
||||
options = AliasOptions(
|
||||
show_sl_domains=False, show_partner_domains=get_proton_partner()
|
||||
)
|
||||
domains = user.get_sl_domains(alias_options=options)
|
||||
assert len(domains) == 2
|
||||
assert domains[0].domain == "premium_partner"
|
||||
assert domains[1].domain == "free_partner"
|
||||
assert [d.domain for d in domains] == user.available_sl_domains(
|
||||
alias_options=options
|
||||
)
|
@ -6,6 +6,7 @@ from email.utils import formataddr
|
||||
import arrow
|
||||
import pytest
|
||||
|
||||
from app import config
|
||||
from app.config import MAX_ALERT_24H, EMAIL_DOMAIN, ROOT_DIR
|
||||
from app.db import Session
|
||||
from app.email_utils import (
|
||||
@ -48,6 +49,8 @@ from app.models import (
|
||||
IgnoreBounceSender,
|
||||
InvalidMailboxDomain,
|
||||
VerpType,
|
||||
AliasGeneratorEnum,
|
||||
SLDomain,
|
||||
)
|
||||
|
||||
# flake8: noqa: E101, W191
|
||||
@ -469,33 +472,55 @@ def test_replace_str():
|
||||
|
||||
def test_generate_reply_email(flask_client):
|
||||
user = create_new_user()
|
||||
reply_email = generate_reply_email("test@example.org", user)
|
||||
assert reply_email.endswith(EMAIL_DOMAIN)
|
||||
alias = Alias.create_new_random(user, AliasGeneratorEnum.uuid.value)
|
||||
Session.commit()
|
||||
reply_email = generate_reply_email("test@example.org", alias)
|
||||
domain = get_email_domain_part(alias.email)
|
||||
assert reply_email.endswith(domain)
|
||||
|
||||
reply_email = generate_reply_email("", user)
|
||||
assert reply_email.endswith(EMAIL_DOMAIN)
|
||||
reply_email = generate_reply_email("", alias)
|
||||
domain = get_email_domain_part(alias.email)
|
||||
assert reply_email.endswith(domain)
|
||||
|
||||
|
||||
def test_generate_reply_email_with_default_reply_domain(flask_client):
|
||||
domain = SLDomain.create(domain=random_domain(), use_as_reverse_alias=False)
|
||||
user = create_new_user()
|
||||
alias = Alias.create(
|
||||
user_id=user.id,
|
||||
email=f"test@{domain.domain}",
|
||||
mailbox_id=user.default_mailbox_id,
|
||||
)
|
||||
Session.commit()
|
||||
reply_email = generate_reply_email("test@example.org", alias)
|
||||
domain = get_email_domain_part(reply_email)
|
||||
assert domain == config.EMAIL_DOMAIN
|
||||
|
||||
|
||||
def test_generate_reply_email_include_sender_in_reverse_alias(flask_client):
|
||||
# user enables include_sender_in_reverse_alias
|
||||
user = create_new_user()
|
||||
alias = Alias.create_new_random(user, AliasGeneratorEnum.uuid.value)
|
||||
Session.commit()
|
||||
user.include_sender_in_reverse_alias = True
|
||||
|
||||
reply_email = generate_reply_email("test@example.org", user)
|
||||
reply_email = generate_reply_email("test@example.org", alias)
|
||||
assert reply_email.startswith("test_at_example_org")
|
||||
assert reply_email.endswith(EMAIL_DOMAIN)
|
||||
domain = get_email_domain_part(alias.email)
|
||||
assert reply_email.endswith(domain)
|
||||
|
||||
reply_email = generate_reply_email("", user)
|
||||
assert reply_email.endswith(EMAIL_DOMAIN)
|
||||
reply_email = generate_reply_email("", alias)
|
||||
domain = get_email_domain_part(alias.email)
|
||||
assert reply_email.endswith(domain)
|
||||
|
||||
reply_email = generate_reply_email("👌汉字@example.org", user)
|
||||
reply_email = generate_reply_email("👌汉字@example.org", alias)
|
||||
assert reply_email.startswith("yizi_at_example_org")
|
||||
|
||||
# make sure reply_email only contain lowercase
|
||||
reply_email = generate_reply_email("TEST@example.org", user)
|
||||
reply_email = generate_reply_email("TEST@example.org", alias)
|
||||
assert reply_email.startswith("test_at_example_org")
|
||||
|
||||
reply_email = generate_reply_email("test.dot@example.org", user)
|
||||
reply_email = generate_reply_email("test.dot@example.org", alias)
|
||||
assert reply_email.startswith("test_dot_at_example_org")
|
||||
|
||||
|
||||
|
@ -8,7 +8,7 @@ from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN, NOREPLY
|
||||
from app.db import Session
|
||||
from app.email_utils import parse_full_address, generate_reply_email
|
||||
from app.models import (
|
||||
generate_email,
|
||||
generate_random_alias_email,
|
||||
Alias,
|
||||
Contact,
|
||||
Mailbox,
|
||||
@ -22,13 +22,13 @@ from tests.utils import login, create_new_user, random_token
|
||||
|
||||
|
||||
def test_generate_email(flask_client):
|
||||
email = generate_email()
|
||||
email = generate_random_alias_email()
|
||||
assert email.endswith("@" + EMAIL_DOMAIN)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
UUID(email.split("@")[0], version=4)
|
||||
|
||||
email_uuid = generate_email(scheme=2)
|
||||
email_uuid = generate_random_alias_email(scheme=2)
|
||||
assert UUID(email_uuid.split("@")[0], version=4)
|
||||
|
||||
|
||||
@ -312,6 +312,6 @@ def test_create_contact_for_noreply(flask_client):
|
||||
user_id=user.id,
|
||||
alias_id=alias.id,
|
||||
website_email=NOREPLY,
|
||||
reply_email=generate_reply_email(NOREPLY, user),
|
||||
reply_email=generate_reply_email(NOREPLY, alias),
|
||||
)
|
||||
assert contact.website_email == NOREPLY
|
||||
|
@ -13,7 +13,7 @@ def test_random_words():
|
||||
assert s.count("_") == 1
|
||||
assert len(s) > 3
|
||||
s = random_words(2, 3)
|
||||
assert s.count("_") == 0
|
||||
assert s.count("_") == 1
|
||||
assert s[-1] in (str(i) for i in range(10))
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user