Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 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
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 @@ -53,6 +54,7 @@
"MetricAdmin",
"InvalidMailboxDomainAdmin",
"ForbiddenMxIpAdmin",
"GlobalSenderBlacklistAdmin",
# Search views
"EmailSearchResult",
"EmailSearchHelpers",
Expand Down
22 changes: 22 additions & 0 deletions app/admin/global_sender_blacklist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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", "user_id")
column_editable_list = ("enabled", "comment")

# 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,
ForbiddenEnvelopeSender,
)
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 @@ -65,3 +67,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(ForbiddenEnvelopeSender, 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
76 changes: 76 additions & 0 deletions app/dashboard/views/setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@
PartnerSubscription,
UnsubscribeBehaviourEnum,
UserAliasDeleteAction,
ForbiddenEnvelopeSender,
)
from app.proton.proton_unlink import can_unlink_proton_account
from app.regex_utils import validate_sender_blacklist_pattern
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import (
random_string,
CSRFValidationForm,
Expand Down Expand Up @@ -285,6 +288,60 @@ 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()
comment = (request.form.get("comment") or "").strip() or None

if len(pattern) > 255:
flash("Pattern too long (max 255 characters)", "warning")
return redirect(url_for("dashboard.setting") + "#sender-blacklist")

err = validate_sender_blacklist_pattern(pattern)
if err:
flash(err, "warning")
return redirect(url_for("dashboard.setting") + "#sender-blacklist")

ForbiddenEnvelopeSender.create(
user_id=current_user.id,
pattern=pattern,
enabled=True,
comment=comment,
commit=True,
)

emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.AddSenderBlacklist,
message=f"Added sender blacklist pattern: {pattern}",
commit=True,
)
flash("Sender blacklist entry added", "success")
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 = ForbiddenEnvelopeSender.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)
Session.commit()

emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.DeleteSenderBlacklist,
message=f"Deleted sender blacklist pattern: {entry.pattern}",
commit=True,
)
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 +353,23 @@ def setting():
if partner_sub_name:
partner_sub, partner_name = partner_sub_name

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

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

return render_template(
"dashboard/setting.html",
csrf_form=csrf_form,
Expand All @@ -318,4 +392,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,
)
27 changes: 27 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3679,6 +3679,33 @@ class ForbiddenMxIp(Base, ModelMixin):
comment = sa.Column(sa.Text, unique=False, nullable=True)


class ForbiddenEnvelopeSender(Base, ModelMixin):
"""Forbidden inbound senders (SMTP 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)

# RFC5321 states that an email address cannot be longer than 254 characters.
pattern = sa.Column(sa.String(255), nullable=False)
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
53 changes: 52 additions & 1 deletion app/regex_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,41 @@
from app.log import LOG


def regex_match(rule_regex: str, local):
# Keep this permissive enough for practical regexes, but still a strict whitelist.
# Note: We intentionally do NOT allow whitespace.
_SENDER_BLACKLIST_ALLOWED_RE = re.compile(
r"^[A-Za-z0-9\[\]\{\}\(\)\|\?\^\$@,._\-\+\*\\\.]+$"
)


def validate_sender_blacklist_pattern(pattern: str) -> str | None:
"""Validate a user-provided sender-blacklist regex pattern.

The goal is to keep patterns simple and prevent expensive/unsafe constructs.
We also validate the regex compiles (re2 preferred).

Returns:
None if valid; otherwise an error message string.
"""
if not pattern:
return "Pattern cannot be empty"

# Keep the allowed character set intentionally small.
if not _SENDER_BLACKLIST_ALLOWED_RE.fullmatch(pattern):
return (
"Invalid characters in pattern. Allowed: letters, digits, and []{}(),._-+*\\.^$@|?"
)

try:
re2.compile(pattern)
except Exception:
return "Invalid regex pattern"

return None


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 +50,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:
"""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
89 changes: 89 additions & 0 deletions app/sender_blacklist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from __future__ import annotations

from cachetools import TTLCache, cached

from app.db import Session
from app.log import LOG
from app.models import ForbiddenEnvelopeSender
from app.regex_utils import regex_match


# Cache enabled patterns to avoid a DB query per inbound email.
#
# TTL: keep changes reasonably fresh while avoiding hammering the DB.
# Memory: cachetools.TTLCache is an in-process dict with an upper bound (maxsize).
_GLOBAL_PATTERNS_CACHE = TTLCache(maxsize=128, ttl=300)
_USER_PATTERNS_CACHE = TTLCache(maxsize=128, ttl=300)


@cached(cache=_GLOBAL_PATTERNS_CACHE)
def _get_enabled_global_patterns() -> list[str]:
return [
r.pattern
for r in Session.query(ForbiddenEnvelopeSender)
.filter(
ForbiddenEnvelopeSender.enabled.is_(True),
ForbiddenEnvelopeSender.user_id.is_(None),
)
.order_by(ForbiddenEnvelopeSender.id.asc())
.all()
]


# Per-user cache: avoid a DB query per email per user, but cap memory via maxsize.
@cached(cache=_USER_PATTERNS_CACHE)
def _get_enabled_user_patterns(user_id: int) -> list[str]:
return [
r.pattern
for r in Session.query(ForbiddenEnvelopeSender)
.filter(
ForbiddenEnvelopeSender.enabled.is_(True),
ForbiddenEnvelopeSender.user_id == user_id,
)
.order_by(ForbiddenEnvelopeSender.id.asc())
.all()
]


def is_sender_blocked_for_user(user_id: int | None, candidates: list[str]) -> bool:
"""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:
# Full-string match to avoid false positives (partial hits).
if regex_match(pattern, candidate):
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
3 changes: 3 additions & 0 deletions app/user_audit_log_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ class UserAuditLogAction(Enum):
UpdateDirectory = "update_directory"
DeleteDirectory = "delete_directory"

AddSenderBlacklist = "add_sender_blacklist"
DeleteSenderBlacklist = "delete_sender_blacklist"

CreateAlias = "create_alias"
UpdateAlias = "update_alias"
DeleteAlias = "delete_alias"
Expand Down
Loading