feat: regex support in email allowlist
Each entry in email_allowlist.json is treated as a re.fullmatch pattern (case-insensitive). Allows domain wildcards, plus-addressing, and any variation expressible as a regex. Invalid patterns are logged and skipped. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ email_send uses the server SMTP config from .env (smtp_server, smtp_from_*).
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from persona import get_user
|
from persona import get_user
|
||||||
@@ -15,11 +16,11 @@ from persona import get_user
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _email_allowlist(username: str) -> list[str]:
|
def _load_allowlist(username: str) -> list[str]:
|
||||||
"""Load the per-user email allowlist. Returns empty list if not configured."""
|
"""Load the per-user email allowlist. Returns empty list if not configured."""
|
||||||
path = settings.home_root() / username / "email_allowlist.json"
|
path = settings.home_root() / username / "email_allowlist.json"
|
||||||
try:
|
try:
|
||||||
return [a.lower().strip() for a in json.loads(path.read_text())]
|
return [str(p).strip() for p in json.loads(path.read_text()) if str(p).strip()]
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -27,18 +28,30 @@ def _email_allowlist(username: str) -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _email_allowed(address: str, patterns: list[str]) -> bool:
|
||||||
|
"""Return True if address matches any pattern (regex, case-insensitive full match)."""
|
||||||
|
addr = address.strip()
|
||||||
|
for pattern in patterns:
|
||||||
|
try:
|
||||||
|
if re.fullmatch(pattern, addr, re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
except re.error:
|
||||||
|
logger.warning("invalid regex in email allowlist: %r", pattern)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def email_send(to: str, subject: str, body: str) -> str:
|
async def email_send(to: str, subject: str, body: str) -> str:
|
||||||
"""Send an email via the server's configured SMTP account."""
|
"""Send an email via the server's configured SMTP account."""
|
||||||
username = get_user()
|
username = get_user()
|
||||||
allowlist = _email_allowlist(username)
|
allowlist = _load_allowlist(username)
|
||||||
|
|
||||||
if not allowlist:
|
if not allowlist:
|
||||||
return (
|
return (
|
||||||
"Email blocked — no allowlist configured. "
|
"Email blocked — no allowlist configured. "
|
||||||
f"Add allowed addresses to home/{username}/email_allowlist.json as a JSON array."
|
f"Add allowed patterns to home/{username}/email_allowlist.json as a JSON array."
|
||||||
)
|
)
|
||||||
if to.lower().strip() not in allowlist:
|
if not _email_allowed(to, allowlist):
|
||||||
return f"Email blocked — {to} is not in the allowlist for {username}."
|
return f"Email blocked — {to} does not match any allowed pattern for {username}."
|
||||||
|
|
||||||
from email_utils import send_email
|
from email_utils import send_email
|
||||||
ok = await asyncio.to_thread(
|
ok = await asyncio.to_thread(
|
||||||
|
|||||||
Reference in New Issue
Block a user