Merge remote-tracking branch 'origin/codex/add-sender-email-validation-and-logging'
This commit is contained in:
@@ -191,6 +191,7 @@ class SendOutreachResponse(BaseModel):
|
||||
status: str
|
||||
policy_allowed: bool
|
||||
policy_reasons: List[str] = Field(default_factory=list)
|
||||
effective_sender_email: Optional[str] = None
|
||||
|
||||
|
||||
class OutreachAttemptRecord(BaseModel):
|
||||
|
||||
@@ -6,9 +6,10 @@ import os
|
||||
import ssl
|
||||
import smtplib
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from typing import Optional
|
||||
from typing import List, Optional, Set
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@@ -17,11 +18,26 @@ SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
||||
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", SMTP_USERNAME)
|
||||
SMTP_ALLOWED_FROM_EMAILS = os.getenv("SMTP_ALLOWED_FROM_EMAILS", "")
|
||||
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() in ("true", "1", "yes")
|
||||
SMTP_VERIFY_TLS = os.getenv("SMTP_VERIFY_TLS", "true").lower() in ("true", "1", "yes")
|
||||
SMTP_SEND_TIMEOUT = int(os.getenv("SMTP_SEND_TIMEOUT", "30"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class SenderAuthorizationResult:
|
||||
authorized: bool
|
||||
effective_sender_email: str = ""
|
||||
failure_reasons: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SendEmailResult:
|
||||
success: bool
|
||||
effective_sender_email: str = ""
|
||||
failure_reasons: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class BacklinkOutreachSender:
|
||||
def __init__(self):
|
||||
self._host = SMTP_HOST
|
||||
@@ -29,6 +45,7 @@ class BacklinkOutreachSender:
|
||||
self._username = SMTP_USERNAME
|
||||
self._password = SMTP_PASSWORD
|
||||
self._from_email = SMTP_FROM_EMAIL or SMTP_USERNAME
|
||||
self._allowed_from_emails = SMTP_ALLOWED_FROM_EMAILS
|
||||
self._use_tls = SMTP_USE_TLS
|
||||
self._verify_tls = SMTP_VERIFY_TLS
|
||||
self._timeout = SMTP_SEND_TIMEOUT
|
||||
@@ -36,18 +53,68 @@ class BacklinkOutreachSender:
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self._username and self._password)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_email(email: Optional[str]) -> str:
|
||||
return (email or "").strip().lower()
|
||||
|
||||
def _allowed_sender_aliases(self) -> Set[str]:
|
||||
aliases = {
|
||||
self._normalize_email(alias)
|
||||
for alias in self._allowed_from_emails.split(",")
|
||||
if self._normalize_email(alias)
|
||||
}
|
||||
for configured_sender in (self._from_email, self._username):
|
||||
normalized = self._normalize_email(configured_sender)
|
||||
if normalized:
|
||||
aliases.add(normalized)
|
||||
return aliases
|
||||
|
||||
def validate_sender_alias(self, from_email: Optional[str] = None) -> SenderAuthorizationResult:
|
||||
default_sender = self._normalize_email(self._from_email or self._username)
|
||||
requested_sender = self._normalize_email(from_email) or default_sender
|
||||
|
||||
if not self.is_configured():
|
||||
return SenderAuthorizationResult(
|
||||
authorized=False,
|
||||
effective_sender_email=requested_sender,
|
||||
failure_reasons=["smtp_not_configured"],
|
||||
)
|
||||
if not requested_sender:
|
||||
return SenderAuthorizationResult(
|
||||
authorized=False,
|
||||
failure_reasons=["smtp_sender_missing"],
|
||||
)
|
||||
|
||||
allowed_aliases = self._allowed_sender_aliases()
|
||||
if requested_sender not in allowed_aliases:
|
||||
return SenderAuthorizationResult(
|
||||
authorized=False,
|
||||
effective_sender_email=requested_sender,
|
||||
failure_reasons=["sender_alias_not_authorized"],
|
||||
)
|
||||
|
||||
return SenderAuthorizationResult(
|
||||
authorized=True,
|
||||
effective_sender_email=requested_sender,
|
||||
)
|
||||
|
||||
async def send_email(
|
||||
self,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
from_email: Optional[str] = None,
|
||||
) -> bool:
|
||||
if not self.is_configured():
|
||||
logger.error("SMTP not configured: set SMTP_USERNAME and SMTP_PASSWORD")
|
||||
return False
|
||||
) -> SendEmailResult:
|
||||
sender_validation = self.validate_sender_alias(from_email)
|
||||
if not sender_validation.authorized:
|
||||
logger.error(f"SMTP sender validation failed: {sender_validation.failure_reasons}")
|
||||
return SendEmailResult(
|
||||
success=False,
|
||||
effective_sender_email=sender_validation.effective_sender_email,
|
||||
failure_reasons=sender_validation.failure_reasons,
|
||||
)
|
||||
|
||||
sender = from_email or self._from_email
|
||||
sender = sender_validation.effective_sender_email
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = sender
|
||||
@@ -78,7 +145,12 @@ class BacklinkOutreachSender:
|
||||
logger.error(f"Unexpected error sending to {to_email}: {e}")
|
||||
return False
|
||||
|
||||
return await loop.run_in_executor(None, _send)
|
||||
success = await loop.run_in_executor(None, _send)
|
||||
return SendEmailResult(
|
||||
success=success,
|
||||
effective_sender_email=sender,
|
||||
failure_reasons=[] if success else ["smtp_send_failed"],
|
||||
)
|
||||
|
||||
def personalize(self, template: str, variables: dict) -> str:
|
||||
"""Replace {placeholder} variables in a template string."""
|
||||
|
||||
@@ -322,6 +322,7 @@ class BacklinkOutreachService:
|
||||
status=attempt.get("status", "failed"),
|
||||
policy_allowed=policy.allowed,
|
||||
policy_reasons=policy.reasons,
|
||||
effective_sender_email=request.sender_email,
|
||||
)
|
||||
|
||||
def get_reporting_snapshot(self, user_id: str = "default") -> Dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user