Validate backlink outreach sender aliases

This commit is contained in:
ي
2026-06-03 18:48:17 +05:30
parent 923fa671fe
commit cbace3b752
6 changed files with 103 additions and 12 deletions

View File

@@ -260,18 +260,29 @@ async def send_outreach(
subject = backlink_outreach_sender.personalize(tmpl.get("subject_template", subject), variables) subject = backlink_outreach_sender.personalize(tmpl.get("subject_template", subject), variables)
body = backlink_outreach_sender.personalize(tmpl.get("body_template", body), 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( result = backlink_outreach_service.send_outreach(
SendOutreachRequest( SendOutreachRequest(
lead_id=payload.lead_id, lead_id=payload.lead_id,
campaign_id=payload.campaign_id, campaign_id=payload.campaign_id,
user_id=user_id, user_id=user_id,
workspace_id=payload.workspace_id, workspace_id=payload.workspace_id,
sender_email=payload.sender_email, sender_email=sender_validation.effective_sender_email,
subject=subject, subject=subject,
body=body, body=body,
idempotency_key=payload.idempotency_key, idempotency_key=payload.idempotency_key,
) )
) )
result.effective_sender_email = sender_validation.effective_sender_email
lead_email = "" lead_email = ""
if result.attempt_id: if result.attempt_id:
@@ -279,15 +290,19 @@ async def send_outreach(
lead_email = (lead.get("email") or "") if lead else "" lead_email = (lead.get("email") or "") if lead else ""
if result.policy_allowed and lead_email: 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, to_email=lead_email,
subject=subject, subject=subject,
body=body, 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) storage.update_attempt_status(result.attempt_id, status, user_id=user_id)
result.status = status 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.mark_idempotency(payload.idempotency_key, user_id)
storage.increment_user_send_counter(user_id) storage.increment_user_send_counter(user_id)
domain = lead_email.split("@")[-1] if "@" in lead_email else "unknown" domain = lead_email.split("@")[-1] if "@" in lead_email else "unknown"

View File

@@ -166,6 +166,7 @@ class SendOutreachResponse(BaseModel):
status: str status: str
policy_allowed: bool policy_allowed: bool
policy_reasons: List[str] = Field(default_factory=list) policy_reasons: List[str] = Field(default_factory=list)
effective_sender_email: Optional[str] = None
class OutreachAttemptRecord(BaseModel): class OutreachAttemptRecord(BaseModel):

View File

@@ -6,9 +6,10 @@ import os
import ssl import ssl
import smtplib import smtplib
import asyncio import asyncio
from dataclasses import dataclass, field
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from typing import Optional from typing import List, Optional, Set
from loguru import logger from loguru import logger
@@ -17,11 +18,26 @@ SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "") SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", SMTP_USERNAME) 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_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_VERIFY_TLS = os.getenv("SMTP_VERIFY_TLS", "true").lower() in ("true", "1", "yes")
SMTP_SEND_TIMEOUT = int(os.getenv("SMTP_SEND_TIMEOUT", "30")) 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: class BacklinkOutreachSender:
def __init__(self): def __init__(self):
self._host = SMTP_HOST self._host = SMTP_HOST
@@ -29,6 +45,7 @@ class BacklinkOutreachSender:
self._username = SMTP_USERNAME self._username = SMTP_USERNAME
self._password = SMTP_PASSWORD self._password = SMTP_PASSWORD
self._from_email = SMTP_FROM_EMAIL or SMTP_USERNAME self._from_email = SMTP_FROM_EMAIL or SMTP_USERNAME
self._allowed_from_emails = SMTP_ALLOWED_FROM_EMAILS
self._use_tls = SMTP_USE_TLS self._use_tls = SMTP_USE_TLS
self._verify_tls = SMTP_VERIFY_TLS self._verify_tls = SMTP_VERIFY_TLS
self._timeout = SMTP_SEND_TIMEOUT self._timeout = SMTP_SEND_TIMEOUT
@@ -36,18 +53,68 @@ class BacklinkOutreachSender:
def is_configured(self) -> bool: def is_configured(self) -> bool:
return bool(self._username and self._password) 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( async def send_email(
self, self,
to_email: str, to_email: str,
subject: str, subject: str,
body: str, body: str,
from_email: Optional[str] = None, from_email: Optional[str] = None,
) -> bool: ) -> SendEmailResult:
if not self.is_configured(): sender_validation = self.validate_sender_alias(from_email)
logger.error("SMTP not configured: set SMTP_USERNAME and SMTP_PASSWORD") if not sender_validation.authorized:
return False 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 = MIMEMultipart("alternative")
msg["From"] = sender msg["From"] = sender
@@ -78,7 +145,12 @@ class BacklinkOutreachSender:
logger.error(f"Unexpected error sending to {to_email}: {e}") logger.error(f"Unexpected error sending to {to_email}: {e}")
return False 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: def personalize(self, template: str, variables: dict) -> str:
"""Replace {placeholder} variables in a template string.""" """Replace {placeholder} variables in a template string."""

View File

@@ -241,6 +241,7 @@ class BacklinkOutreachService:
status=attempt.get("status", "failed"), status=attempt.get("status", "failed"),
policy_allowed=policy.allowed, policy_allowed=policy.allowed,
policy_reasons=policy.reasons, policy_reasons=policy.reasons,
effective_sender_email=request.sender_email,
) )
def get_reporting_snapshot(self, user_id: str = "default") -> Dict[str, Any]: def get_reporting_snapshot(self, user_id: str = "default") -> Dict[str, Any]:

View File

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

View File

@@ -708,6 +708,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' }}> <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> <span style={{ color: 'rgba(255,255,255,0.5)' }}>Latest: {a.subject} </span>
{renderStatusBadge(a.status)} {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>} {a.sent_at && <span style={{ color: 'rgba(255,255,255,0.3)', marginLeft: '8px' }}>{new Date(a.sent_at).toLocaleString()}</span>}
</div> </div>
))} ))}
@@ -724,7 +725,7 @@ const BacklinkOutreachDashboard: React.FC = () => {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead> <thead>
<tr style={{ background: 'rgba(255,255,255,0.04)' }}> <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> <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> </tr>