Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
25d224d
Initial commit of "blocking domains"
tozo Dec 10, 2024
ca985b9
adding blocked domain check to the email handler
tozo Dec 10, 2024
7670844
adding blocked domain check to the email handler
tozo Dec 10, 2024
f0e0b6d
adding tests, fixing bugs
tozo Dec 11, 2024
5e7beb7
changing wording
tozo Dec 11, 2024
cd13303
updating ui
tozo Dec 11, 2024
095d93f
Two fixes (#2357)
acasajus Jan 15, 2025
f94a1d2
feat: use rye as project manager (#2359)
cquintana92 Jan 16, 2025
ab9d956
Align lock file as much as we can with the old versions (#2360)
acasajus Jan 16, 2025
77bf9d6
build: specify rye artifacts
cquintana92 Jan 16, 2025
328a333
Align requirements.lock
acasajus Jan 16, 2025
33e0ac7
Remove poetry.lock and set boto3 back to 1.35
acasajus Jan 17, 2025
3af788d
Update yacron
acasajus Jan 17, 2025
6134da8
Run actions on push tags
acasajus Jan 20, 2025
a50cb60
Run actions on v* tags
acasajus Jan 20, 2025
8f023d0
Set uv as package manager (#2361)
acasajus Jan 20, 2025
59cb338
Add missing rollback (#2364)
acasajus Jan 21, 2025
48e6bd2
Refactor coupon management and send proper events (#2363)
acasajus Jan 23, 2025
2d53dd2
Extract mailbox email change into an util (#2366)
acasajus Jan 24, 2025
ddd546d
Handle multiple emails in reply-to header (#2365)
acasajus Jan 24, 2025
4bf3864
Prevent accounts created from partner directly to unlink
acasajus Jan 24, 2025
dbb20a0
Hide the full connect with proton section
acasajus Jan 27, 2025
14489f3
Fix: allow reply-contact to be none
acasajus Jan 27, 2025
e5b37ac
Fix unsub parsing
acasajus Jan 28, 2025
d024381
Manage parse_address error
acasajus Jan 28, 2025
d32f1f6
feat: add request_id in log (#2367)
cquintana92 Jan 29, 2025
42793ed
feat: index cleanup and add missing ones (#2369)
cquintana92 Jan 31, 2025
0d4c4c1
fix: remove duplicate EmailLog index (#2371)
cquintana92 Jan 31, 2025
5a28860
Multi domain check (#2372)
acasajus Jan 31, 2025
1a02257
Do not allow lifetime coupons for lifetime partneri (#2376)
acasajus Feb 3, 2025
da2da73
chore: more index work (#2377)
cquintana92 Feb 3, 2025
c6262fb
Require user to be logged in for email change verification
acasajus Feb 4, 2025
a2dddb7
Handle the case for duplicate new_email (#2378)
acasajus Feb 4, 2025
c0afe52
Send event on account unlink (#2379)
acasajus Feb 5, 2025
36500ba
chore: offer version in newrelic events (#2380)
cquintana92 Feb 5, 2025
f9dfce6
Proper check of mx domains (#2382)
acasajus Feb 5, 2025
2a89c12
Clear new_email on verified email change (#2383)
acasajus Feb 6, 2025
ddeb5c4
Merge branch 'master' into master
tozo Feb 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions app/custom_domain_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@

from dataclasses import dataclass
from enum import Enum
from typing import List, Optional
from typing import List, Optional, Type

from app.config import JOB_DELETE_DOMAIN
from app.db import Session
from app.email_utils import get_email_domain_part
from app.log import LOG
from app.models import User, CustomDomain, SLDomain, Mailbox, Job, DomainMailbox
from app.models import (
User,
CustomDomain,
SLDomain,
Mailbox,
Job,
DomainMailbox,
ModelMixin,
)
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction

_ALLOWED_DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$")
Expand Down Expand Up @@ -89,12 +97,14 @@ def sanitize_domain(domain: str) -> str:
return new_domain


def can_domain_be_used(user: User, domain: str) -> Optional[CannotUseDomainReason]:
def can_domain_be_used(
user: User, domain: str, model_type: Type[ModelMixin]
) -> Optional[CannotUseDomainReason]:
if not is_valid_domain(domain):
return CannotUseDomainReason.InvalidDomain
elif SLDomain.get_by(domain=domain):
return CannotUseDomainReason.BuiltinDomain
elif CustomDomain.get_by(domain=domain):
elif model_type.get_by(domain=domain):
return CannotUseDomainReason.DomainAlreadyUsed
elif get_email_domain_part(user.email) == domain:
return CannotUseDomainReason.DomainPartOfUserEmail
Expand All @@ -116,7 +126,7 @@ def create_custom_domain(
)

new_domain = sanitize_domain(domain)
domain_forbidden_cause = can_domain_be_used(user, new_domain)
domain_forbidden_cause = can_domain_be_used(user, new_domain, CustomDomain)
if domain_forbidden_cause:
return CreateCustomDomainResult(
message=domain_forbidden_cause.message(new_domain), message_category="error"
Expand Down
36 changes: 36 additions & 0 deletions app/dashboard/views/setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ALIAS_RANDOM_SUFFIX_LENGTH,
CONNECT_WITH_PROTON,
)
from app.custom_domain_utils import sanitize_domain, can_domain_be_used
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.errors import ProtonPartnerNotSetUp
Expand All @@ -40,6 +41,7 @@
PartnerUser,
PartnerSubscription,
UnsubscribeBehaviourEnum,
BlockedDomain,
)
from app.proton.proton_partner import get_proton_partner
from app.proton.proton_unlink import can_unlink_proton_account
Expand Down Expand Up @@ -99,6 +101,12 @@ def setting():
else:
pending_email = None

blocked_domains = (
BlockedDomain.filter_by(user_id=current_user.id)
.order_by(BlockedDomain.created_at.desc())
.all()
)

if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
Expand Down Expand Up @@ -289,6 +297,33 @@ def setting():
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "blocked-domains-add":
domain = request.form.get("domain-name")
new_domain = sanitize_domain(domain)
domain_forbidden_cause = can_domain_be_used(
current_user, new_domain, BlockedDomain
)

if domain_forbidden_cause:
flash(domain_forbidden_cause.message(new_domain), "error")
return redirect(url_for("dashboard.setting"))

BlockedDomain.create(user_id=current_user.id, domain=new_domain)

Session.commit()
flash(f"Added blocked domain [{domain}]", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "blocked-domains-remove":
domain_id = request.form.get("domain_id")
domain_name = request.form.get("domain_name")

domain = BlockedDomain.get(domain_id)

BlockedDomain.delete(domain.id)

Session.commit()
flash(f"Deleted blocked domain [{domain_name}]", "success")
return redirect(url_for("dashboard.setting"))

manual_sub = ManualSubscription.get_by(user_id=current_user.id)
apple_sub = AppleSubscription.get_by(user_id=current_user.id)
Expand Down Expand Up @@ -325,4 +360,5 @@ def setting():
connect_with_proton=CONNECT_WITH_PROTON,
proton_linked_account=proton_linked_account,
can_unlink_proton_account=can_unlink_proton_account(current_user),
blocked_domains=blocked_domains,
)
25 changes: 25 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,8 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):

default_mailbox = orm.relationship("Mailbox", foreign_keys=[default_mailbox_id])

_blocked_domains = orm.relationship("BlockedDomain", lazy="joined")

# user can set a more strict max_spam score to block spams more aggressively
max_spam_score = sa.Column(sa.Integer, nullable=True)

Expand Down Expand Up @@ -1205,6 +1207,15 @@ def has_used_alias_from_partner(self) -> bool:
> 0
)

def is_domain_blocked(self, domain: str) -> bool:
"""checks whether the provided domain is blocked for a given user"""
domain_names = []

for blocked_domain in BlockedDomain.filter_by(user_id=self.id):
domain_names.append(blocked_domain.domain)

return any(domain in domain_name for domain_name in domain_names)

def __repr__(self):
return f"<User {self.id} {self.name} {self.email}>"

Expand Down Expand Up @@ -3090,6 +3101,20 @@ class DomainMailbox(Base, ModelMixin):
)


class BlockedDomain(Base, ModelMixin):
"""store the blocked domains for a user"""

__tablename__ = "blocked_domain"

__table_args__ = (
sa.UniqueConstraint("user_id", "domain", name="uq_blocked_domain"),
)

domain = sa.Column(sa.String(128), nullable=False)

user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)


_NB_RECOVERY_CODE = 8
_RECOVERY_CODE_LENGTH = 8

Expand Down
9 changes: 9 additions & 0 deletions email_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,15 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
handle_email_sent_to_ourself(alias, addr, msg, user)
return [(True, status.E209)]

mail_from_domain = get_email_domain_part(mail_from)
if user.is_domain_blocked(mail_from_domain):
# by default return 2** instead of 5** to allow user to receive emails again when domain is unblocked
res_status = status.E200
if user.block_behaviour == BlockBehaviourEnum.return_5xx:
res_status = status.E502

return [(True, res_status)]

from_header = get_header_unicode(msg[headers.FROM])
LOG.d("Create or get contact for from_header:%s", from_header)
contact = get_or_create_contact(from_header, envelope.mail_from, alias)
Expand Down
38 changes: 38 additions & 0 deletions migrations/versions/2024_121017_32696502b574_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""empty message

Revision ID: 32696502b574
Revises: 085f77996ce3
Create Date: 2024-12-10 17:57:01.043030

"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '32696502b574'
down_revision = '085f77996ce3'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('blocked_domain',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('domain', sa.String(length=128), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'domain', name='uq_blocked_domain')
)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('blocked_domain')
# ### end Alembic commands ###
50 changes: 50 additions & 0 deletions templates/dashboard/setting.html
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,56 @@
</div>
</div>
<!-- END Alias import/export -->
<!-- Blocked domains -->
<div class="card" id="blocked-domains">
<div class="card-body">
<div class="card-title">Blocked domains</div>
<div class="mb-3">You can add domains to prevent emails forwarded from them to your Mailbox.</div>
<hr />
<h4>New domain</h4>
<form method="post" action="#blocked-domains">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="blocked-domains-add">
<div class="form-group">
<input class="form-control mr-2"
name="domain-name"
placeholder="Domain to be blocked">
</div>
<button class="btn btn-outline-primary mt-2">Add</button>
</form>
{% if blocked_domains | length > 0 %}

<div class="mt-2">
<hr />
<h4>Blocked domains</h4>
<div class="row">
{% for blocked_domain in blocked_domains %}

<div class="col-6">
<div class="my-2 p-2 card">
<div class="row">
<div class="col">
<div class="py-2 font-weight-bold">{{ blocked_domain.domain }}</div>
</div>
<div class="col-2">
<form method="post" action="#blocked-domains">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="blocked-domains-remove">
<input type="hidden" name="domain_name" value="{{ blocked_domain.domain }}">
<input type="hidden" name="domain_id" value="{{ blocked_domain.id }}">
<button class="btn btn-link text-danger">Delete</button>
</form>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
<!-- END Blocked domains -->
</div>
{% endblock %}
{% block script %}
Expand Down
21 changes: 14 additions & 7 deletions tests/test_custom_domain_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
CannotSetCustomDomainMailboxesCause,
)
from app.db import Session
from app.models import User, CustomDomain, Mailbox, DomainMailbox
from app.models import User, CustomDomain, Mailbox, DomainMailbox, BlockedDomain
from tests.utils import create_new_user, random_string, random_domain
from tests.utils import get_proton_partner, random_email

Expand Down Expand Up @@ -50,42 +50,49 @@ def test_is_valid_domain():
# can_domain_be_used
def test_can_domain_be_used():
domain = f"{random_string(10)}.com"
res = can_domain_be_used(user, domain)
res = can_domain_be_used(user, domain, CustomDomain)
assert res is None


def test_can_domain_be_used_existing_domain():
domain = random_domain()
CustomDomain.create(user_id=user.id, domain=domain, commit=True)
res = can_domain_be_used(user, domain)
res = can_domain_be_used(user, domain, CustomDomain)
assert res is CannotUseDomainReason.DomainAlreadyUsed


def test_can_domain_be_used_sl_domain():
domain = ALIAS_DOMAINS[0]
res = can_domain_be_used(user, domain)
res = can_domain_be_used(user, domain, CustomDomain)
assert res is CannotUseDomainReason.BuiltinDomain


def test_can_domain_be_used_domain_of_user_email():
domain = user.email.split("@")[1]
res = can_domain_be_used(user, domain)
res = can_domain_be_used(user, domain, CustomDomain)
assert res is CannotUseDomainReason.DomainPartOfUserEmail


def test_can_domain_be_used_domain_of_existing_mailbox():
domain = random_domain()
Mailbox.create(user_id=user.id, email=f"email@{domain}", verified=True, commit=True)
res = can_domain_be_used(user, domain)
res = can_domain_be_used(user, domain, CustomDomain)
assert res is CannotUseDomainReason.DomainUserInMailbox


def test_can_domain_be_used_invalid_domain():
domain = f"{random_string(10)}@lol.com"
res = can_domain_be_used(user, domain)
res = can_domain_be_used(user, domain, CustomDomain)
assert res is CannotUseDomainReason.InvalidDomain


def test_can_blocked_domain_be_used_existing_domain():
domain = random_domain()
BlockedDomain.create(user_id=user.id, domain=domain, commit=True)
res = can_domain_be_used(user, domain, BlockedDomain)
assert res is CannotUseDomainReason.DomainAlreadyUsed


# sanitize_domain
def test_can_sanitize_domain_empty():
assert sanitize_domain("") == ""
Expand Down
17 changes: 16 additions & 1 deletion tests/test_domains.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from app.db import Session
from app.models import SLDomain, PartnerUser, AliasOptions
from app.models import SLDomain, PartnerUser, AliasOptions, BlockedDomain
from app.proton.proton_partner 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()
Session.query(BlockedDomain).delete()
SLDomain.create(
domain="hidden", premium_only=False, flush=True, order=5, hidden=True
)
Expand All @@ -33,6 +34,7 @@ def setup_module():

def teardown_module():
Session.query(SLDomain).delete()
Session.query(BlockedDomain).delete()
add_sl_domains()


Expand Down Expand Up @@ -227,3 +229,16 @@ def test_get_free_partner_and_premium_partner():
assert [d.domain for d in domains] == user.available_sl_domains(
alias_options=options
)


def test_domain_blocked_for_user():
user = create_new_user()
BlockedDomain.create(
user_id=user.id,
domain="example.com",
flush=True,
)
Session.flush()

assert user.is_domain_blocked("example.com")
assert not user.is_domain_blocked("some-other-example.com")
Loading