Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cc17c20
Add global sender blacklist (regex) with admin UI and SMTP blocking
lio-chrisblech Mar 8, 2026
90bd0cb
Blacklist: only apply when no Contact exists; create disabled contact
lio-chrisblech Mar 17, 2026
2b8f535
Refactor contact creation + add pattern help text
lio-chrisblech Mar 17, 2026
e298007
Add per-user sender blacklist
lio-chrisblech Mar 18, 2026
c39dca2
DB: add user_id to global_sender_blacklist
lio-chrisblech Mar 18, 2026
96c1fbc
Fix sender blacklist UI text, show global entries, and make migration…
lio-chrisblech Mar 18, 2026
cfdbbfb
cleanup, only show active global entries
chrisblech Mar 18, 2026
1aeab12
Rename GlobalSenderBlacklist model to ForbiddenEnvelopeSender
lio-chrisblech Mar 25, 2026
0011360
Admin: show all forbidden envelope sender entries
lio-chrisblech Mar 25, 2026
203f8dd
Sender blacklist: cache patterns 5min, use fullmatch and explicit types
lio-chrisblech Mar 25, 2026
2ca7a77
Email handler: apply sender blacklist before creating contacts
lio-chrisblech Mar 25, 2026
851f58e
Dashboard: comment, validation and audit log for sender blacklist
lio-chrisblech Mar 25, 2026
28554a8
Deps: add cachetools
lio-chrisblech Mar 25, 2026
1f6496e
Tests: update model name for sender blacklist
lio-chrisblech Mar 25, 2026
43173d1
Adjust sender blacklist validation, contact precedence, and cache sizing
lio-chrisblech Mar 25, 2026
32223c3
Merge pull request #3 from chrisblech/feature/user-blacklists-review-…
chrisblech Mar 28, 2026
748227c
Merge branch 'master' into feature/user-blacklists
chrisblech Apr 2, 2026
60979ba
Merge branch 'master' into feature/user-blacklists
chrisblech Apr 28, 2026
bfb269a
reorder migrations
chrisblech Apr 28, 2026
a79100b
Merge branch 'master' into feature/user-blacklists
chrisblech May 12, 2026
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
2 changes: 2 additions & 0 deletions app/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from app.admin.metrics import DailyMetricAdmin, MetricAdmin
from app.admin.invalid_mailbox_domain import InvalidMailboxDomainAdmin
from app.admin.forbidden_mx_ip import ForbiddenMxIpAdmin
from app.admin.global_sender_blacklist import GlobalSenderBlacklistAdmin
from app.admin.email_search import (
EmailSearchResult,
EmailSearchHelpers,
Expand Down Expand Up @@ -52,6 +53,7 @@
"MetricAdmin",
"InvalidMailboxDomainAdmin",
"ForbiddenMxIpAdmin",
"GlobalSenderBlacklistAdmin",
# Search views
"EmailSearchResult",
"EmailSearchHelpers",
Expand Down
36 changes: 36 additions & 0 deletions app/admin/global_sender_blacklist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from flask_admin.form import SecureForm

from app.admin.base import SLModelView


class GlobalSenderBlacklistAdmin(SLModelView):
form_base_class = SecureForm

can_create = True
can_edit = True
can_delete = True

column_searchable_list = ("pattern", "comment")
column_filters = ("enabled",)
column_editable_list = ("enabled", "comment")

# Keep the admin UI strictly on GLOBAL entries (user_id is NULL)
column_exclude_list = ("user_id", "user")
form_excluded_columns = ("user_id", "user")
Comment thread
chrisblech marked this conversation as resolved.
Outdated

def get_query(self):
return (
super().get_query().filter(self.model.user_id.is_(None)) # type: ignore[attr-defined]
)

def get_count_query(self):
return (
super().get_count_query().filter(self.model.user_id.is_(None)) # type: ignore[attr-defined]
)

# Help text for admins when adding patterns
form_args = {
"pattern": {
"description": r"Regex, i.e. `@domain\.com$`",
}
}
3 changes: 3 additions & 0 deletions app/admin/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
Metric2,
InvalidMailboxDomain,
ForbiddenMxIp,
GlobalSenderBlacklist,
)
from app.admin.base import SLAdminIndexView
from app.admin.user import UserAdmin
Expand All @@ -31,6 +32,7 @@
from app.admin.metrics import DailyMetricAdmin, MetricAdmin
from app.admin.invalid_mailbox_domain import InvalidMailboxDomainAdmin
from app.admin.forbidden_mx_ip import ForbiddenMxIpAdmin
from app.admin.global_sender_blacklist import GlobalSenderBlacklistAdmin
from app.admin.email_search import EmailSearchAdmin
from app.admin.custom_domain_search import CustomDomainSearchAdmin
from app.admin.abuser_lookup import AbuserLookupAdmin
Expand Down Expand Up @@ -63,3 +65,4 @@ def init_admin(app: Flask):
admin.add_view(MetricAdmin(Metric2, Session))
admin.add_view(InvalidMailboxDomainAdmin(InvalidMailboxDomain, Session))
admin.add_view(ForbiddenMxIpAdmin(ForbiddenMxIp, Session))
admin.add_view(GlobalSenderBlacklistAdmin(GlobalSenderBlacklist, Session))
2 changes: 2 additions & 0 deletions app/contact_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def create_contact(
mail_from: Optional[str] = None,
allow_empty_email: bool = False,
automatic_created: bool = False,
block_forward: bool = False,
from_partner: bool = False,
) -> ContactCreateResult:
LOG.i(
Expand Down Expand Up @@ -105,6 +106,7 @@ def create_contact(
automatic_created=automatic_created,
flags=flags,
invalid_email=is_invalid_email,
block_forward=block_forward,
commit=True,
)
contact_id = contact.id
Expand Down
53 changes: 53 additions & 0 deletions app/dashboard/views/setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
PartnerSubscription,
UnsubscribeBehaviourEnum,
UserAliasDeleteAction,
GlobalSenderBlacklist,
)
from app.proton.proton_unlink import can_unlink_proton_account
from app.utils import (
Expand Down Expand Up @@ -285,6 +286,39 @@ def setting():
Session.commit()
flash("Your preference has been updated", "success")

elif request.form.get("form-name") == "user-sender-blacklist-add":
pattern = (request.form.get("pattern") or "").strip()
if not pattern:
Comment thread
chrisblech marked this conversation as resolved.
Outdated
flash("Pattern cannot be empty", "warning")
return redirect(url_for("dashboard.setting") + "#sender-blacklist")

GlobalSenderBlacklist.create(
user_id=current_user.id,
pattern=pattern,
enabled=True,
comment=None,
Comment thread
chrisblech marked this conversation as resolved.
Outdated
commit=True,
)
flash("Sender blacklist entry added", "success")
Comment thread
chrisblech marked this conversation as resolved.
return redirect(url_for("dashboard.setting") + "#sender-blacklist")

elif request.form.get("form-name") == "user-sender-blacklist-delete":
try:
entry_id = int(request.form.get("entry-id"))
except Exception:
flash("Invalid request", "warning")
return redirect(url_for("dashboard.setting") + "#sender-blacklist")

entry = GlobalSenderBlacklist.get_by(id=entry_id)
if entry is None or entry.user_id != current_user.id:
flash("Not found", "warning")
return redirect(url_for("dashboard.setting") + "#sender-blacklist")

Session.delete(entry)
Comment thread
chrisblech marked this conversation as resolved.
Session.commit()
flash("Sender blacklist entry deleted", "success")
return redirect(url_for("dashboard.setting") + "#sender-blacklist")

manual_sub = ManualSubscription.get_by(user_id=current_user.id)
apple_sub = AppleSubscription.get_by(user_id=current_user.id)
coinbase_sub = CoinbaseSubscription.get_by(user_id=current_user.id)
Expand All @@ -296,6 +330,23 @@ def setting():
if partner_sub_name:
partner_sub, partner_name = partner_sub_name

user_sender_blacklist_entries = (
Session.query(GlobalSenderBlacklist)
.filter(GlobalSenderBlacklist.user_id == current_user.id)
.order_by(GlobalSenderBlacklist.id.asc())
.all()
)

global_sender_blacklist_entries = (
Session.query(GlobalSenderBlacklist)
.filter(
GlobalSenderBlacklist.enabled.is_(True),
GlobalSenderBlacklist.user_id.is_(None),
)
.order_by(GlobalSenderBlacklist.id.asc())
.all()
)

return render_template(
"dashboard/setting.html",
csrf_form=csrf_form,
Expand All @@ -318,4 +369,6 @@ def setting():
ALIAS_RAND_SUFFIX_LENGTH=ALIAS_RANDOM_SUFFIX_LENGTH,
connect_with_proton=CONNECT_WITH_PROTON,
can_unlink_proton_account=can_unlink_proton_account(current_user),
user_sender_blacklist_entries=user_sender_blacklist_entries,
global_sender_blacklist_entries=global_sender_blacklist_entries,
)
26 changes: 26 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3672,6 +3672,32 @@ class ForbiddenMxIp(Base, ModelMixin):
comment = sa.Column(sa.Text, unique=False, nullable=True)


class GlobalSenderBlacklist(Base, ModelMixin):
Comment thread
acasajus marked this conversation as resolved.
Outdated
"""Global blacklist for inbound senders (envelope MAIL FROM).

Pattern is a (re2-compatible) regex that is applied via search() against the
full envelope sender address.

Examples:
- "@spamdomain\\.com$"
- "^no-?reply@.*"
"""

__tablename__ = "global_sender_blacklist"

# NULL user_id => global blacklist entry (admin-managed)
# non-NULL user_id => per-user blacklist entry (user-managed)
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=True)

pattern = sa.Column(sa.String(512), nullable=False)
Comment thread
chrisblech marked this conversation as resolved.
Outdated
enabled = sa.Column(sa.Boolean, nullable=False, default=True, server_default="1")
comment = sa.Column(sa.Text, nullable=True)

user = orm.relationship(User)

__table_args__ = (sa.Index("ix_global_sender_blacklist_user_id", "user_id"),)


# region Phone
class PhoneCountry(Base, ModelMixin):
__tablename__ = "phone_country"
Expand Down
20 changes: 19 additions & 1 deletion app/regex_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from app.log import LOG


def regex_match(rule_regex: str, local):
def regex_match(rule_regex: str, local) -> bool:
"""Return True if *full string* matches rule_regex."""
regex = re2.compile(rule_regex)
try:
if re2.fullmatch(regex, local):
Expand All @@ -16,3 +17,20 @@ def regex_match(rule_regex: str, local):
if re.fullmatch(regex, local):
return True
return False


def regex_search(rule_regex: str, text: str) -> bool:
Comment thread
chrisblech marked this conversation as resolved.
"""Return True if any substring of text matches rule_regex.

Uses re2 when possible to avoid catastrophic backtracking.
"""
regex = re2.compile(rule_regex)
try:
if re2.search(regex, text):
return True
except TypeError: # re2 bug "Argument 'pattern' has incorrect type (expected bytes, got PythonRePattern)"
LOG.w("use re instead of re2 for %s %s", rule_regex, text)
regex = re.compile(rule_regex)
if re.search(regex, text):
return True
return False
82 changes: 82 additions & 0 deletions app/sender_blacklist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from __future__ import annotations

from cachetools import TTLCache, cached
Comment thread
chrisblech marked this conversation as resolved.

from app.db import Session
from app.log import LOG
from app.models import GlobalSenderBlacklist
from app.regex_utils import regex_search


# Cache enabled patterns briefly to avoid a DB query per inbound email.
# Admin changes should take effect quickly but don't need to be instant.
@cached(cache=TTLCache(maxsize=128, ttl=30))
Comment thread
chrisblech marked this conversation as resolved.
Outdated
def _get_enabled_global_patterns() -> list[str]:
return [
r.pattern
for r in Session.query(GlobalSenderBlacklist)
.filter(
GlobalSenderBlacklist.enabled.is_(True),
GlobalSenderBlacklist.user_id.is_(None),
)
.order_by(GlobalSenderBlacklist.id.asc())
.all()
]


# Per-user cache: keep it small-ish but avoid a DB query per email per user.
@cached(cache=TTLCache(maxsize=128, ttl=30))
def _get_enabled_user_patterns(user_id: int) -> list[str]:
Comment thread
chrisblech marked this conversation as resolved.
return [
r.pattern
for r in Session.query(GlobalSenderBlacklist)
.filter(
GlobalSenderBlacklist.enabled.is_(True),
GlobalSenderBlacklist.user_id == user_id,
)
.order_by(GlobalSenderBlacklist.id.asc())
.all()
]


def is_sender_blocked_for_user(user_id: int | None, *candidates: str) -> bool:
Comment thread
chrisblech marked this conversation as resolved.
Outdated
"""Return True if any candidate sender string matches:

- the global sender blacklist (user_id is NULL), OR
- the given user's sender blacklist (user_id matches)

Typical candidates:
- SMTP envelope MAIL FROM
- parsed header From address
"""

patterns: list[str] = []
patterns.extend(_get_enabled_global_patterns())
if user_id is not None:
patterns.extend(_get_enabled_user_patterns(int(user_id)))

if not patterns:
return False

for candidate in candidates:
if not candidate:
continue
# Ignore bounce/null reverse-path
if candidate == "<>":
continue

for pattern in patterns:
try:
if regex_search(pattern, candidate):
Comment thread
chrisblech marked this conversation as resolved.
Outdated
return True
except Exception:
# Never crash the SMTP handler because of a bad regex.
# (Global or user entry — both are user-provided.)
LOG.exception(
"Sender blacklist regex failed: user_id=%s pattern=%s candidate=%s",
user_id,
pattern,
candidate,
)

return False
25 changes: 25 additions & 0 deletions email_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@
from app.handler.unsubscribe_generator import UnsubscribeGenerator
from app.handler.unsubscribe_handler import UnsubscribeHandler
from app.log import LOG, set_message_id
from app.sender_blacklist import is_sender_blocked_for_user
from app.mail_sender import sl_sendmail
from app.mailbox_utils import (
get_mailbox_for_reply_phase,
Expand Down Expand Up @@ -213,13 +214,37 @@ def get_or_create_contact(
mail_from,
)
contact_email = mail_from

Comment thread
chrisblech marked this conversation as resolved.
# Decide whether we already have a matching contact BEFORE applying the global sender blacklist.
# This allows users to whitelist a specific sender by manually creating/enabling a Contact.
sanitized_contact_email = sanitize_email(contact_email, not_lower=True)
Comment thread
chrisblech marked this conversation as resolved.
Outdated
existing_contact = Contact.get_by(
alias_id=alias.id, website_email=sanitized_contact_email
)

# Only consult the global blacklist if NO matching contact exists yet.
# If matched, create a disabled Contact; the existing block_forward logic will refuse the email.
block_forward = False
if existing_contact is None:
block_forward = is_sender_blocked_for_user(
alias.user_id, mail_from, sanitized_contact_email
)
if block_forward:
LOG.i(
"Sender matched sender blacklist (global or user); creating disabled contact: mail_from=%s header_from=%s alias=%s",
mail_from,
sanitized_contact_email,
alias,
)

contact_result = contact_utils.create_contact(
email=contact_email,
alias=alias,
name=contact_name,
mail_from=mail_from,
allow_empty_email=True,
automatic_created=True,
block_forward=block_forward,
from_partner=False,
)
if contact_result.error:
Expand Down
Loading