Merge remote-tracking branch 'origin/codex/add-sender-email-validation-and-logging'

This commit is contained in:
ajaysi
2026-06-03 18:50:53 +05:30
6 changed files with 103 additions and 12 deletions

View File

@@ -319,13 +319,23 @@ async def send_outreach(
subject = backlink_outreach_sender.personalize(tmpl.get("subject_template", subject), variables)
body = backlink_outreach_sender.personalize(tmpl.get("body_template", body), variables)
sender_validation = backlink_outreach_sender.validate_sender_alias(payload.sender_email)
if not sender_validation.authorized:
return SendOutreachResponse(
attempt_id="",
status="failed",
policy_allowed=False,
policy_reasons=sender_validation.failure_reasons,
effective_sender_email=sender_validation.effective_sender_email or None,
)
result = backlink_outreach_service.send_outreach(
SendOutreachRequest(
lead_id=payload.lead_id,
campaign_id=payload.campaign_id,
user_id=user_id,
workspace_id=payload.workspace_id,
sender_email=payload.sender_email,
sender_email=sender_validation.effective_sender_email,
subject=subject,
body=body,
idempotency_key=payload.idempotency_key,
@@ -340,6 +350,7 @@ async def send_outreach(
one_click_unsubscribe=payload.one_click_unsubscribe,
)
)
result.effective_sender_email = sender_validation.effective_sender_email
lead_email = ""
if result.attempt_id:
@@ -347,15 +358,19 @@ async def send_outreach(
lead_email = (lead.get("email") or "") if lead else ""
if result.policy_allowed and lead_email:
sent = await backlink_outreach_sender.send_email(
send_result = await backlink_outreach_sender.send_email(
to_email=lead_email,
subject=subject,
body=body,
from_email=payload.sender_email,
)
status = "sent" if sent else "failed"
status = "sent" if send_result.success else "failed"
storage.update_attempt_status(result.attempt_id, status, user_id=user_id)
result.status = status
if sent:
result.effective_sender_email = send_result.effective_sender_email or result.effective_sender_email
if send_result.failure_reasons:
result.policy_reasons = (result.policy_reasons or []) + send_result.failure_reasons
if send_result.success:
storage.mark_idempotency(payload.idempotency_key, user_id)
storage.increment_user_send_counter(user_id)
domain = lead_email.split("@")[-1] if "@" in lead_email else "unknown"

View File

@@ -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):

View File

@@ -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."""

View File

@@ -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]:

View File

@@ -221,6 +221,7 @@ export interface SendOutreachResponse {
status: string;
policy_allowed: boolean;
policy_reasons: string[];
effective_sender_email?: string | null;
}
export interface OutreachAttemptRecord {

View File

@@ -745,6 +745,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
<div key={a.attempt_id} style={{ marginTop: '8px', padding: '8px 12px', background: 'rgba(255,255,255,0.04)', borderRadius: '8px', fontSize: '12px' }}>
<span style={{ color: 'rgba(255,255,255,0.5)' }}>Latest: {a.subject} </span>
{renderStatusBadge(a.status)}
{a.sender_email && <span style={{ color: 'rgba(255,255,255,0.35)', marginLeft: '8px' }}>From: {a.sender_email}</span>}
{a.sent_at && <span style={{ color: 'rgba(255,255,255,0.3)', marginLeft: '8px' }}>{new Date(a.sent_at).toLocaleString()}</span>}
</div>
))}
@@ -761,7 +762,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ background: 'rgba(255,255,255,0.04)' }}>
{['Subject', 'Status', 'Sender', 'Sent At'].map(h => (
{['Subject', 'Status', 'Effective Sender', 'Sent At'].map(h => (
<th key={h} style={{ padding: '10px 12px', borderBottom: '1px solid rgba(255,255,255,0.08)', textAlign: 'left', color: 'rgba(255,255,255,0.4)', fontWeight: 500, fontSize: '12px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>{h}</th>
))}
</tr>