Compare commits
3 Commits
Author | SHA1 | Date | |
---|---|---|---|
2c82b03f8d | |||
1b7a6223ac | |||
75331c62a4 |
@ -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(
|
||||
|
@ -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")
|
||||
|
@ -298,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)
|
||||
@ -426,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
|
||||
@ -564,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)
|
||||
@ -1293,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
|
||||
@ -1312,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):
|
||||
@ -1519,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(
|
||||
@ -1548,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:
|
||||
@ -1556,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,
|
||||
@ -1595,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(
|
||||
@ -1714,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)
|
||||
@ -2125,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)
|
||||
@ -2848,7 +2871,7 @@ class SLDomain(Base, ModelMixin):
|
||||
sa.ForeignKey(Partner.id, ondelete="cascade"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
sever_default="NULL",
|
||||
server_default="NULL",
|
||||
)
|
||||
|
||||
# if enabled, do not show this domain when user creates a custom alias
|
||||
@ -2857,6 +2880,10 @@ class SLDomain(Base, ModelMixin):
|
||||
# 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'}"
|
||||
|
||||
@ -3366,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)
|
||||
|
||||
|
@ -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,10 +1019,14 @@ 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):
|
||||
LOG.w(f"Reply email {reply_email} has wrong domain")
|
||||
return False, status.E501
|
||||
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
|
||||
|
||||
# handle case where reply email is generated with non-allowed char
|
||||
reply_email = normalize_reply_email(reply_email)
|
||||
@ -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()
|
||||
|
||||
|
@ -19,7 +19,7 @@ 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, sever_default='NULL'))
|
||||
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 ###
|
||||
|
||||
|
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
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -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() %}
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user