Merge remote-tracking branch 'origin/codex/add-sender-email-validation-and-logging'
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -221,6 +221,7 @@ export interface SendOutreachResponse {
|
||||
status: string;
|
||||
policy_allowed: boolean;
|
||||
policy_reasons: string[];
|
||||
effective_sender_email?: string | null;
|
||||
}
|
||||
|
||||
export interface OutreachAttemptRecord {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user